Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a048f35099 | ||
|
|
22cef67804 | ||
|
|
3bc8c69def | ||
|
|
798e5ed7a7 | ||
|
|
eef709e475 | ||
|
|
04158ac8ea | ||
|
|
ae0b66311d | ||
|
|
b97abe5328 | ||
|
|
d94314627f | ||
|
|
7ec6a57ddf | ||
|
|
35b539e35b | ||
|
|
3b040e6bed | ||
|
|
9300be03f0 | ||
|
|
137d4f3f60 | ||
|
|
e723b348cf | ||
|
|
59fb603ec6 | ||
|
|
63f072ebfb | ||
|
|
0913d04d33 | ||
|
|
80ff108fc0 | ||
|
|
ea58129ebe | ||
|
|
698869460c | ||
|
|
f3f2f63110 | ||
|
|
b100cdc382 | ||
|
|
9e90b32991 | ||
|
|
b2d54cb2e9 | ||
|
|
1e5e47b889 | ||
|
|
dd28b0d165 | ||
|
|
49e74e2f58 | ||
|
|
e6e5d761c2 | ||
|
|
e1ce808567 | ||
|
|
3c7ad816d9 | ||
|
|
f44dd343da | ||
|
|
2d9b2d50f3 | ||
|
|
003e0f9614 | ||
|
|
da0979b373 | ||
|
|
81fb57639e | ||
|
|
1e61078e93 | ||
|
|
b597bb1b17 | ||
|
|
65ead05dfc | ||
|
|
6f8ed69ee1 | ||
|
|
59b879d2df | ||
|
|
a57b0b9b58 | ||
|
|
d8506efed6 | ||
|
|
b25b28cc76 | ||
|
|
f68cabe226 | ||
|
|
f73f83c5d6 | ||
|
|
29801d6a65 | ||
|
|
75016e8bfe | ||
|
|
b6dea7b2ba | ||
|
|
b2b1cd7e97 | ||
|
|
ca63e01d45 | ||
|
|
271b6e1f5c | ||
|
|
609ddf9e01 |
@@ -1,6 +1,7 @@
|
||||
name: Deploy Knowledge
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
on:
|
||||
@@ -18,6 +19,48 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_branch == 'deploy/konwledge'
|
||||
steps:
|
||||
- name: Wait for KnowledgeFS CI
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
timeout-minutes: 35
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const workflowId = "knowledge-fs-ci.yml";
|
||||
const headBranch = context.payload.workflow_run.head_branch;
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
const deadline = Date.now() + 30 * 60 * 1000;
|
||||
const pollIntervalMs = 15 * 1000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
branch: headBranch,
|
||||
event: "push",
|
||||
head_sha: headSha,
|
||||
per_page: 10,
|
||||
});
|
||||
const run = data.workflow_runs[0];
|
||||
|
||||
if (!run) {
|
||||
core.info(`Waiting for ${workflowId} to start for ${headSha}.`);
|
||||
} else if (run.status !== "completed") {
|
||||
core.info(`Waiting for ${run.html_url}; current status is ${run.status}.`);
|
||||
} else if (run.conclusion !== "success") {
|
||||
throw new Error(
|
||||
`${workflowId} did not succeed for ${headSha}: ${run.conclusion} (${run.html_url})`,
|
||||
);
|
||||
} else {
|
||||
core.info(`KnowledgeFS CI succeeded for ${headSha}: ${run.html_url}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${workflowId} to succeed for ${headSha}.`);
|
||||
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
|
||||
@@ -63,6 +63,7 @@ jobs:
|
||||
E2E_ADMIN_PASSWORD: E2eAdmin12345
|
||||
E2E_FORCE_WEB_BUILD: "1"
|
||||
E2E_INIT_PASSWORD: E2eInit12345
|
||||
E2E_START_AGENT_BACKEND: "1"
|
||||
run: vp run e2e:full
|
||||
|
||||
- name: Preserve Chromium E2E report and logs
|
||||
|
||||
@@ -666,6 +666,7 @@ PLUGIN_REMOTE_INSTALL_PORT=5003
|
||||
PLUGIN_REMOTE_INSTALL_HOST=localhost
|
||||
PLUGIN_MAX_PACKAGE_SIZE=15728640
|
||||
PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED=true
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_TTL=86400
|
||||
# Comma-separated marketplace plugin IDs whose latest versions are installed for newly registered users.
|
||||
# Example: langgenius/openai,langgenius/gemini
|
||||
|
||||
+2
-2
@@ -99,9 +99,9 @@ ENV VIRTUAL_ENV=/app/api/.venv
|
||||
COPY --from=packages --chown=dify:dify ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
|
||||
|
||||
# Download nltk data
|
||||
RUN mkdir -p /usr/local/share/nltk_data \
|
||||
&& NLTK_DATA=/usr/local/share/nltk_data python -c "import nltk; nltk.download('punkt'); nltk.download('averaged_perceptron_tagger'); nltk.download('stopwords')" \
|
||||
&& NLTK_DATA=/usr/local/share/nltk_data python -m nltk.downloader punkt_tab averaged_perceptron_tagger_eng stopwords \
|
||||
&& NLTK_DATA=/usr/local/share/nltk_data python -c "import nltk; nltk.data.find('tokenizers/punkt_tab'); nltk.data.find('taggers/averaged_perceptron_tagger_eng'); nltk.data.find('corpora/stopwords')" \
|
||||
&& chmod -R 755 /usr/local/share/nltk_data
|
||||
|
||||
ENV TIKTOKEN_CACHE_DIR=/app/api/.tiktoken_cache
|
||||
|
||||
@@ -5,8 +5,6 @@ API adapters: request building from Dify product concepts, a thin client wrapper
|
||||
event adaptation for future workflow integration, and deterministic fakes.
|
||||
"""
|
||||
|
||||
from dify_agent.protocol import RuntimeLayerSpec, extract_runtime_layer_specs
|
||||
|
||||
from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackendRunClient
|
||||
from clients.agent_backend.errors import (
|
||||
AgentBackendError,
|
||||
@@ -47,11 +45,6 @@ from clients.agent_backend.request_builder import (
|
||||
AgentBackendWorkflowNodeRunInput,
|
||||
redact_for_agent_backend_log,
|
||||
)
|
||||
from clients.agent_backend.session_cleanup import (
|
||||
AgentBackendSessionCleanupPayload,
|
||||
AgentBackendSessionCleanupResult,
|
||||
cleanup_agent_backend_session,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AGENT_SOUL_PROMPT_LAYER_ID",
|
||||
@@ -80,8 +73,6 @@ __all__ = [
|
||||
"AgentBackendRunRequestBuilder",
|
||||
"AgentBackendRunStartedInternalEvent",
|
||||
"AgentBackendRunSucceededInternalEvent",
|
||||
"AgentBackendSessionCleanupPayload",
|
||||
"AgentBackendSessionCleanupResult",
|
||||
"AgentBackendStreamError",
|
||||
"AgentBackendStreamInternalEvent",
|
||||
"AgentBackendTransportError",
|
||||
@@ -90,9 +81,6 @@ __all__ = [
|
||||
"DifyAgentBackendRunClient",
|
||||
"FakeAgentBackendRunClient",
|
||||
"FakeAgentBackendScenario",
|
||||
"RuntimeLayerSpec",
|
||||
"cleanup_agent_backend_session",
|
||||
"create_agent_backend_run_client",
|
||||
"extract_runtime_layer_specs",
|
||||
"redact_for_agent_backend_log",
|
||||
]
|
||||
|
||||
@@ -16,7 +16,6 @@ from collections.abc import Mapping
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from agenton.compositor.schemas import LayerSessionSnapshot
|
||||
from agenton.layers import ExitIntent
|
||||
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
|
||||
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID
|
||||
@@ -37,6 +36,7 @@ from dify_agent.layers.execution_context import (
|
||||
)
|
||||
from dify_agent.layers.knowledge import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig
|
||||
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
|
||||
from dify_agent.layers.runtime import DIFY_RUNTIME_LAYER_TYPE_ID, DifyRuntimeLayerConfig
|
||||
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||
from dify_agent.protocol import (
|
||||
DIFY_AGENT_HISTORY_LAYER_ID,
|
||||
@@ -47,7 +47,6 @@ from dify_agent.protocol import (
|
||||
LayerExitSignals,
|
||||
RunComposition,
|
||||
RunLayerSpec,
|
||||
RuntimeLayerSpec,
|
||||
)
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator
|
||||
|
||||
@@ -56,6 +55,7 @@ WORKFLOW_NODE_JOB_PROMPT_LAYER_ID = "workflow_node_job_prompt"
|
||||
WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt"
|
||||
AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt"
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
|
||||
DIFY_RUNTIME_LAYER_ID = "runtime"
|
||||
DIFY_CONFIG_LAYER_ID = "config"
|
||||
DIFY_DRIVE_LAYER_ID = "drive"
|
||||
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
|
||||
@@ -66,25 +66,11 @@ DIFY_SHELL_LAYER_ID = "shell"
|
||||
type AgentConfigVersionKind = Literal["snapshot", "draft", "build_draft"]
|
||||
|
||||
|
||||
def _filter_snapshot_to_specs(
|
||||
snapshot: CompositorSessionSnapshot,
|
||||
specs: list[RuntimeLayerSpec],
|
||||
) -> CompositorSessionSnapshot:
|
||||
"""Keep only snapshot layers whose names appear in the cleanup spec list.
|
||||
|
||||
The agenton compositor rejects a snapshot whose layer-name sequence does
|
||||
not match the active composition exactly. Cleanup-replay drops plugin
|
||||
layers, so we must drop the matching snapshot entries here.
|
||||
"""
|
||||
kept_names = {spec.name for spec in specs}
|
||||
filtered_layers: list[LayerSessionSnapshot] = [layer for layer in snapshot.layers if layer.name in kept_names]
|
||||
if len(filtered_layers) == len(snapshot.layers):
|
||||
return snapshot
|
||||
return CompositorSessionSnapshot(schema_version=snapshot.schema_version, layers=filtered_layers)
|
||||
|
||||
|
||||
def _shell_layer_deps() -> dict[str, str]:
|
||||
return {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
return {
|
||||
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
"runtime": DIFY_RUNTIME_LAYER_ID,
|
||||
}
|
||||
|
||||
|
||||
def _drive_layer_deps() -> dict[str, str]:
|
||||
@@ -214,6 +200,7 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
|
||||
|
||||
model: AgentBackendModelConfig
|
||||
execution_context: DifyExecutionContextLayerConfig
|
||||
backend_binding_ref: str = Field(min_length=1)
|
||||
workflow_node_job_prompt: str
|
||||
user_prompt: str
|
||||
agent_soul_prompt: str | None = None
|
||||
@@ -231,8 +218,8 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
|
||||
# the Agent Soul configures human involvement; a deferred call ends the run and
|
||||
# the workflow pauses via the existing HITL form mechanism (ENG-635).
|
||||
ask_human_config: DifyAskHumanLayerConfig | None = None
|
||||
# Inject the sandboxed shell layer (dify.shell). Requires the agent backend
|
||||
# to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED.
|
||||
# Inject the sandboxed shell graph. Requires a deployment-selected runtime
|
||||
# backend plus the product-resolved persistent Binding.
|
||||
include_shell: bool = False
|
||||
shell_config: DifyShellLayerConfig | None = None
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
@@ -240,7 +227,6 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
|
||||
# (ENG-638). Keyed by the original deferred tool_call_id.
|
||||
deferred_tool_results: DeferredToolResultsPayload | None = None
|
||||
include_history: bool = True
|
||||
suspend_on_exit: bool = True
|
||||
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||
@@ -264,6 +250,7 @@ class AgentBackendAgentAppRunInput(BaseModel):
|
||||
|
||||
model: AgentBackendModelConfig
|
||||
execution_context: DifyExecutionContextLayerConfig
|
||||
backend_binding_ref: str = Field(min_length=1)
|
||||
user_prompt: str
|
||||
agent_soul_prompt: str | None = None
|
||||
agent_config_version_kind: AgentConfigVersionKind = "snapshot"
|
||||
@@ -279,8 +266,8 @@ class AgentBackendAgentAppRunInput(BaseModel):
|
||||
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
|
||||
# the Agent Soul configures human involvement (ENG-635).
|
||||
ask_human_config: DifyAskHumanLayerConfig | None = None
|
||||
# Inject the sandboxed shell layer (dify.shell). Requires the agent backend
|
||||
# to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED.
|
||||
# Inject the sandboxed shell graph. Requires a deployment-selected runtime
|
||||
# backend plus the product-resolved persistent Binding.
|
||||
include_shell: bool = False
|
||||
shell_config: DifyShellLayerConfig | None = None
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
@@ -288,7 +275,6 @@ class AgentBackendAgentAppRunInput(BaseModel):
|
||||
# (ENG-638). Keyed by the original deferred tool_call_id.
|
||||
deferred_tool_results: DeferredToolResultsPayload | None = None
|
||||
include_history: bool = True
|
||||
suspend_on_exit: bool = True
|
||||
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||
@@ -350,6 +336,14 @@ class AgentBackendRunRequestBuilder:
|
||||
run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
|
||||
)
|
||||
if include_shell:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_RUNTIME_LAYER_ID,
|
||||
type=DIFY_RUNTIME_LAYER_TYPE_ID,
|
||||
metadata=run_input.metadata,
|
||||
config=DifyRuntimeLayerConfig(backend_binding_ref=run_input.backend_binding_ref),
|
||||
)
|
||||
)
|
||||
# Sandboxed bash workspace (dify.shell). It enters before config/drive
|
||||
# so eager pulls materialize content in the same filesystem used by
|
||||
# model commands.
|
||||
@@ -481,53 +475,7 @@ class AgentBackendRunRequestBuilder:
|
||||
metadata=run_input.metadata,
|
||||
session_snapshot=run_input.session_snapshot,
|
||||
deferred_tool_results=run_input.deferred_tool_results,
|
||||
on_exit=LayerExitSignals(
|
||||
default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE,
|
||||
),
|
||||
)
|
||||
|
||||
def build_cleanup_request(
|
||||
self,
|
||||
*,
|
||||
session_snapshot: CompositorSessionSnapshot,
|
||||
runtime_layer_specs: list[RuntimeLayerSpec],
|
||||
idempotency_key: str | None = None,
|
||||
metadata: dict[str, JsonValue] | None = None,
|
||||
) -> CreateRunRequest:
|
||||
"""Build a lifecycle-only cleanup request that replays the prior layers.
|
||||
|
||||
The agenton compositor enforces that the session snapshot's layer names
|
||||
match the active composition in order, so cleanup must replay the same
|
||||
non-plugin layer graph that produced the snapshot. Plugin layers
|
||||
(``dify.plugin.llm``, ``dify.plugin.tools``) are excluded from both the
|
||||
composition and the snapshot before submission because their configs
|
||||
may carry credentials or runtime-only declarations that are not
|
||||
persisted between runs.
|
||||
"""
|
||||
if not runtime_layer_specs:
|
||||
raise ValueError(
|
||||
"build_cleanup_request requires runtime_layer_specs; an empty "
|
||||
"composition would fail the agent backend's snapshot validation."
|
||||
)
|
||||
request_metadata = dict(metadata or {})
|
||||
request_metadata["agent_backend_lifecycle"] = "session_cleanup"
|
||||
layers = [
|
||||
RunLayerSpec(
|
||||
name=spec.name,
|
||||
type=spec.type,
|
||||
deps=dict(spec.deps),
|
||||
metadata=dict(spec.metadata),
|
||||
config=spec.config,
|
||||
)
|
||||
for spec in runtime_layer_specs
|
||||
]
|
||||
filtered_snapshot = _filter_snapshot_to_specs(session_snapshot, runtime_layer_specs)
|
||||
return CreateRunRequest(
|
||||
composition=RunComposition(layers=layers),
|
||||
idempotency_key=idempotency_key,
|
||||
metadata=request_metadata,
|
||||
session_snapshot=filtered_snapshot,
|
||||
on_exit=LayerExitSignals(default=ExitIntent.DELETE),
|
||||
on_exit=LayerExitSignals(default=ExitIntent.SUSPEND),
|
||||
)
|
||||
|
||||
def build_for_workflow_node(self, run_input: AgentBackendWorkflowNodeRunInput) -> CreateRunRequest:
|
||||
@@ -580,6 +528,14 @@ class AgentBackendRunRequestBuilder:
|
||||
run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
|
||||
)
|
||||
if include_shell:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_RUNTIME_LAYER_ID,
|
||||
type=DIFY_RUNTIME_LAYER_TYPE_ID,
|
||||
metadata=run_input.metadata,
|
||||
config=DifyRuntimeLayerConfig(backend_binding_ref=run_input.backend_binding_ref),
|
||||
)
|
||||
)
|
||||
# Sandboxed bash workspace (dify.shell). It enters before drive so
|
||||
# drive can materialize mentioned targets with `dify-agent drive pull`
|
||||
# in the same shell-visible filesystem used by model commands.
|
||||
@@ -713,9 +669,7 @@ class AgentBackendRunRequestBuilder:
|
||||
metadata=run_input.metadata,
|
||||
session_snapshot=run_input.session_snapshot,
|
||||
deferred_tool_results=run_input.deferred_tool_results,
|
||||
on_exit=LayerExitSignals(
|
||||
default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE,
|
||||
),
|
||||
on_exit=LayerExitSignals(default=ExitIntent.SUSPEND),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
"""Shared API-side helper for Agent backend lifecycle-only session cleanup.
|
||||
|
||||
Product code owns local row retirement and background-task dispatch. This module
|
||||
only adapts persisted cleanup inputs into the public ``dify-agent`` run
|
||||
protocol, performs the synchronous ``create_run + wait_run`` loop used by Celery
|
||||
workers, and reports whether the backend cleanup succeeded, was skipped, or
|
||||
failed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from dify_agent.protocol import RuntimeLayerSpec
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
||||
|
||||
from clients.agent_backend.client import AgentBackendRunClient
|
||||
from clients.agent_backend.errors import AgentBackendError
|
||||
from clients.agent_backend.request_builder import AgentBackendRunRequestBuilder
|
||||
|
||||
|
||||
class AgentBackendSessionCleanupPayload(BaseModel):
|
||||
"""Serialized cleanup inputs preserved across API and Celery boundaries."""
|
||||
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
runtime_layer_specs: list[RuntimeLayerSpec] = Field(default_factory=list)
|
||||
idempotency_key: str | None = None
|
||||
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
timeout_seconds: float = 30.0
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentBackendSessionCleanupResult:
|
||||
"""Terminal outcome of one backend cleanup attempt."""
|
||||
|
||||
status: Literal["succeeded", "skipped", "failed"]
|
||||
reason: str | None = None
|
||||
cleanup_run_id: str | None = None
|
||||
|
||||
@classmethod
|
||||
def succeeded(cls, cleanup_run_id: str) -> AgentBackendSessionCleanupResult:
|
||||
return cls(status="succeeded", cleanup_run_id=cleanup_run_id)
|
||||
|
||||
@classmethod
|
||||
def skipped(cls, reason: str) -> AgentBackendSessionCleanupResult:
|
||||
return cls(status="skipped", reason=reason)
|
||||
|
||||
@classmethod
|
||||
def failed(cls, reason: str, cleanup_run_id: str | None = None) -> AgentBackendSessionCleanupResult:
|
||||
return cls(status="failed", reason=reason, cleanup_run_id=cleanup_run_id)
|
||||
|
||||
|
||||
def cleanup_agent_backend_session(
|
||||
*,
|
||||
payload: AgentBackendSessionCleanupPayload,
|
||||
client: AgentBackendRunClient | None,
|
||||
request_builder: AgentBackendRunRequestBuilder | None = None,
|
||||
) -> AgentBackendSessionCleanupResult:
|
||||
"""Run lifecycle-only cleanup against the Agent backend and report status."""
|
||||
if client is None:
|
||||
return AgentBackendSessionCleanupResult.skipped("no_agent_backend_client")
|
||||
if payload.session_snapshot is None:
|
||||
return AgentBackendSessionCleanupResult.skipped("missing_session_snapshot")
|
||||
if not payload.runtime_layer_specs:
|
||||
return AgentBackendSessionCleanupResult.skipped("missing_runtime_layer_specs")
|
||||
|
||||
builder = request_builder or AgentBackendRunRequestBuilder()
|
||||
request = builder.build_cleanup_request(
|
||||
session_snapshot=payload.session_snapshot,
|
||||
runtime_layer_specs=payload.runtime_layer_specs,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
metadata=payload.metadata,
|
||||
)
|
||||
|
||||
try:
|
||||
response = client.create_run(request)
|
||||
except AgentBackendError as exc:
|
||||
return AgentBackendSessionCleanupResult.failed(str(exc))
|
||||
|
||||
try:
|
||||
status_response = client.wait_run(response.run_id, timeout_seconds=payload.timeout_seconds)
|
||||
except AgentBackendError as exc:
|
||||
return AgentBackendSessionCleanupResult.failed(str(exc), cleanup_run_id=response.run_id)
|
||||
|
||||
if status_response.status != "succeeded":
|
||||
reason = status_response.error or f"cleanup run ended with status {status_response.status}"
|
||||
return AgentBackendSessionCleanupResult.failed(reason, cleanup_run_id=response.run_id)
|
||||
|
||||
return AgentBackendSessionCleanupResult.succeeded(response.run_id)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentBackendSessionCleanupPayload",
|
||||
"AgentBackendSessionCleanupResult",
|
||||
"cleanup_agent_backend_session",
|
||||
]
|
||||
@@ -44,9 +44,8 @@ class AgentBackendConfig(BaseSettings):
|
||||
|
||||
AGENT_SHELL_ENABLED: bool = Field(
|
||||
description=(
|
||||
"Inject the dify.shell layer (sandboxed bash workspace) into Agent runs. "
|
||||
"Requires the agent backend to be wired with a shellctl entrypoint before "
|
||||
"shell-using Agent runs are executed."
|
||||
"Inject the Home, Workspace, Sandbox, and Shell runtime layers into Agent runs. "
|
||||
"Requires Dify Agent to have a deployment-selected runtime backend."
|
||||
),
|
||||
default=True,
|
||||
)
|
||||
|
||||
@@ -266,6 +266,12 @@ class PluginConfig(BaseSettings):
|
||||
default=60 * 60,
|
||||
)
|
||||
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED: bool = Field(
|
||||
description="Whether tenant plugin model providers are cached in Redis. Disable when plugins are installed "
|
||||
"by a system other than this one, which cannot invalidate the cache when a tenant's plugins change.",
|
||||
default=True,
|
||||
)
|
||||
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_TTL: PositiveInt = Field(
|
||||
description="TTL in seconds for caching tenant plugin model providers in Redis",
|
||||
default=60 * 60 * 24,
|
||||
|
||||
@@ -25,8 +25,6 @@ HUMAN_INPUT_FORM_INPUT_EXAMPLE = {
|
||||
|
||||
|
||||
class HumanInputFormSubmitPayload(BaseModel):
|
||||
"""Legacy Human Input v1 submit payload shared by existing runtime surfaces."""
|
||||
|
||||
inputs: dict[str, JsonValue] = Field(
|
||||
description=(
|
||||
"Submitted human input values keyed by output variable name. "
|
||||
|
||||
@@ -1,984 +0,0 @@
|
||||
"""Shared Human Input v2 transport contracts.
|
||||
|
||||
Request DTOs use normal Pydantic coercion and forbid unknown fields. Migration
|
||||
input is the sole compatibility exception: it ignores unknown legacy fields,
|
||||
defaults a missing version to ``"1"``, rejects any other explicit version, and
|
||||
rejects duplicate node IDs. Its transport shape mirrors the frontend migration
|
||||
adapter so the generated client can replace the temporary mock without changing
|
||||
frontend orchestration.
|
||||
Public v2, trusted Service API v2, and legacy v1 submit DTOs stay independent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated, Literal, Self, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Discriminator, Field, JsonValue, field_validator, model_validator
|
||||
|
||||
from core.human_input_v2.entities import (
|
||||
ContactId,
|
||||
EmailProviderType,
|
||||
HumanInputContactType,
|
||||
IMBindingId,
|
||||
IMBindingScope,
|
||||
IMIdentityBindingStatus,
|
||||
IMIdentityId,
|
||||
IMIntegrationStatus,
|
||||
IMProvider,
|
||||
IMSyncRemovalReason,
|
||||
IMSyncResultType,
|
||||
IMSyncRunId,
|
||||
IMSyncRunStatus,
|
||||
OrganizationCandidateId,
|
||||
)
|
||||
from core.workflow.nodes.human_input.entities import FormInputConfig, UserActionConfig
|
||||
from core.workflow.nodes.human_input.entities import HumanInputNodeDataFull as HITLv1NodeData
|
||||
from core.workflow.nodes.human_input_v2.entities import Channel
|
||||
from core.workflow.nodes.human_input_v2.entities import HumanInputNodeData as HITLv2NodeData
|
||||
from fields.base import ResponseModel
|
||||
from fields.pagination import PaginationParamsMixin, PaginationResultMixin
|
||||
from fields.timestamp import Timestamp
|
||||
from libs.helper import EmailStr
|
||||
|
||||
|
||||
class _NoExtraModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class _RequestModel(BaseModel):
|
||||
"""Base request model that forbids unknown fields while accepting JSON-native values."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class _MigrationInputModel(BaseModel):
|
||||
"""Forward-compatible migration input that ignores fields unknown to this backend version."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
class ContactListQuery(PaginationParamsMixin, _NoExtraModel):
|
||||
"""Query params for listing contacts in the workspace directory."""
|
||||
|
||||
group: HumanInputContactType | None = Field(
|
||||
default=None,
|
||||
description="Optional contact type filter. None means all contacts.",
|
||||
)
|
||||
keyword: str | None = Field(default=None, description="Free-text search against contact name or email.")
|
||||
|
||||
|
||||
class ContactOptionsQuery(PaginationParamsMixin, _NoExtraModel):
|
||||
"""Query params for selecting contacts in workflow editors."""
|
||||
|
||||
keyword: str | None = Field(default=None, description="Free-text search against selectable contact names.")
|
||||
|
||||
|
||||
class OrganizationCandidatesQuery(PaginationParamsMixin, _NoExtraModel):
|
||||
"""Query params for searching organization member candidates."""
|
||||
|
||||
keyword: str | None = Field(default=None, description="Free-text search against candidate name or email.")
|
||||
|
||||
|
||||
ExternalContactName = Annotated[
|
||||
str,
|
||||
Field(
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description="Display name shown in the contact directory.",
|
||||
),
|
||||
]
|
||||
|
||||
ExternalContactEmail = Annotated[
|
||||
EmailStr,
|
||||
Field(
|
||||
description="Primary email used for delivery and identity verification.",
|
||||
),
|
||||
]
|
||||
|
||||
ExternalContactAvatar = Annotated[
|
||||
str,
|
||||
Field(
|
||||
description=(
|
||||
"Optional avatar file ID. Upload the avatar image first via "
|
||||
"`POST /console/api/files/upload`, then use the returned file id here."
|
||||
" Set to empty string for resetting to default avatar."
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class ExternalContactCreateRequest(_RequestModel):
|
||||
"""Request body for creating or updating one external contact."""
|
||||
|
||||
name: ExternalContactName
|
||||
email: ExternalContactEmail
|
||||
avatar: ExternalContactAvatar | None = None
|
||||
|
||||
|
||||
class ExternalContactUpdateRequest(_RequestModel):
|
||||
"""Request body for creating or updating one external contact."""
|
||||
|
||||
name: ExternalContactName | None = None
|
||||
email: ExternalContactEmail | None = None
|
||||
avatar: ExternalContactAvatar | None = None
|
||||
|
||||
|
||||
class IMBinding(BaseModel):
|
||||
id: IMBindingId = Field(description="Unique IM binding identifier.")
|
||||
provider: IMProvider = Field(description="Provider of the IM binding.")
|
||||
scope: IMBindingScope = Field(description="Scope of the IM binding.")
|
||||
|
||||
|
||||
class HumanInputContactSummary(BaseModel):
|
||||
"""A trimmed version of `HumanInputContact` that only includes the fields needed for workflow orchestration."""
|
||||
|
||||
id: ContactId = Field(description="Unique contact identifier.")
|
||||
name: str = Field(description="Display name shown in the contact directory.")
|
||||
avatar_url: str = Field(default="", description="URL of the contact's avatar.")
|
||||
created_at: Timestamp = Field(description="Timestamp when the contact was created.")
|
||||
|
||||
|
||||
class HumanInputContact(BaseModel):
|
||||
"""One contact entity returned by contact-related APIs."""
|
||||
|
||||
id: ContactId = Field(description="Unique contact identifier.")
|
||||
type: HumanInputContactType = Field(description="Resolved contact type in the current workspace scope.")
|
||||
name: str = Field(description="Display name shown in the contact directory.")
|
||||
email: str | None = Field(default=None, description="Primary contact email if one exists.")
|
||||
avatar_url: str = Field(default="", description="URL of the contact's avatar.")
|
||||
# the `im_bindings` field is always empty for EXTERNAL contacts
|
||||
im_bindings: list[IMBinding] = Field(
|
||||
default_factory=list[IMBinding],
|
||||
description=(
|
||||
"IM bindings that are bound to this contact. "
|
||||
"Currently, only one IM binding is supported. "
|
||||
"There is at most one IM binding per IM provider."
|
||||
),
|
||||
)
|
||||
|
||||
created_at: Timestamp = Field(description="Timestamp when the contact was created.")
|
||||
|
||||
|
||||
class ContactOption(ResponseModel):
|
||||
"""Least-privilege contact projection returned to workflow editors."""
|
||||
|
||||
id: ContactId = Field(description="Unique contact identifier persisted in workflow recipient configuration.")
|
||||
type: HumanInputContactType = Field(description="Resolved contact type in the current workspace scope.")
|
||||
name: str = Field(description="Display name shown in the contact picker.")
|
||||
avatar_url: str | None = Field(default=None, description="Signed avatar URL if one is available.")
|
||||
|
||||
|
||||
class ExternalContactCreateResponse(ResponseModel):
|
||||
contact: HumanInputContact = Field(description="The created external contact.")
|
||||
|
||||
|
||||
class ExternalContactUpdateResponse(ResponseModel):
|
||||
contact: HumanInputContact = Field(description="The updated external contact. Fields are values after updating.")
|
||||
|
||||
|
||||
class OrganizationCandidate(ResponseModel):
|
||||
"""One organization member candidate that may become a platform contact."""
|
||||
|
||||
id: OrganizationCandidateId = Field(description="Organization candidate identifier.")
|
||||
name: str = Field(description="Display name shown in the candidate list.")
|
||||
email: str = Field(description="Primary organization email used for matching.")
|
||||
avatar_url: str | None = Field(default=None, description="Signed avatar URL if one is available.")
|
||||
|
||||
|
||||
class ListContactsResponse(PaginationResultMixin, ResponseModel):
|
||||
"""Paginated response body for contact list APIs."""
|
||||
|
||||
data: list[HumanInputContact] = Field(description="Contacts returned for the current page.")
|
||||
|
||||
|
||||
class ListContactOptionsResponse(PaginationResultMixin, ResponseModel):
|
||||
"""Paginated editor-safe contact picker response."""
|
||||
|
||||
data: list[ContactOption] = Field(description="Selectable contacts returned for the current page.")
|
||||
|
||||
|
||||
class GetContactResponse(ResponseModel):
|
||||
"""Response body for one contact resolved in the current workspace scope."""
|
||||
|
||||
contact: HumanInputContact = Field(description="Contact resolved as workspace, platform, or external.")
|
||||
|
||||
|
||||
class ListOrganizationCandidatesResponse(PaginationResultMixin, ResponseModel):
|
||||
"""Paginated response body for organization candidate search."""
|
||||
|
||||
data: list[OrganizationCandidate] = Field(
|
||||
description="Organization member candidates returned for the current page."
|
||||
)
|
||||
|
||||
|
||||
class AddPlatformContactsRequest(_RequestModel):
|
||||
"""Request body for adding one or more organization members as platform contacts."""
|
||||
|
||||
candidate_ids: list[OrganizationCandidateId] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Organization candidate identifiers to project into the current workspace as platform contacts.",
|
||||
)
|
||||
|
||||
|
||||
class AddPlatformContactsResponse(ResponseModel):
|
||||
"""Response body for adding platform contacts."""
|
||||
|
||||
data: list[HumanInputContact] = Field(description="Contacts created by the current add operation.")
|
||||
|
||||
|
||||
class RemoveContactsRequest(_RequestModel):
|
||||
"""Request body for batch-removing platform or external contacts."""
|
||||
|
||||
contact_ids: list[ContactId] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Contact identifiers selected for removal from the contact directory surface.",
|
||||
)
|
||||
|
||||
|
||||
class RemoveContactsResponse(ResponseModel):
|
||||
"""Response body returned after batch-removing contacts."""
|
||||
|
||||
removed_contact_ids: list[ContactId] = Field(description="Contact identifiers removed by the current operation.")
|
||||
|
||||
|
||||
class _FeishuLarkIMIntegrationCredentialsBase(_RequestModel):
|
||||
"""Shared credential fields for Feishu and Lark integrations."""
|
||||
|
||||
app_id: str = Field(description="Feishu or Lark application identifier.")
|
||||
app_secret: str | PreserveOriginalValue = Field(description="Feishu or Lark application secret.")
|
||||
verification_token: str | PreserveOriginalValue | None = Field(
|
||||
default=None, description="Optional callback verification token."
|
||||
)
|
||||
encrypt_key: str | PreserveOriginalValue | None = Field(default=None, description="Optional callback encrypt key.")
|
||||
|
||||
|
||||
class FeishuIMIntegrationCredentials(_FeishuLarkIMIntegrationCredentialsBase):
|
||||
"""Feishu integration credentials used by organization-level IM setup."""
|
||||
|
||||
provider: Literal[IMProvider.FEISHU] = Field(description="Discriminator for Feishu integration credentials.")
|
||||
|
||||
|
||||
class LarkIMIntegrationCredentials(_FeishuLarkIMIntegrationCredentialsBase):
|
||||
"""Lark integration credentials used by organization-level IM setup."""
|
||||
|
||||
provider: Literal[IMProvider.LARK] = Field(description="Discriminator for Lark integration credentials.")
|
||||
|
||||
|
||||
class SlackIMIntegrationCredentials(_RequestModel):
|
||||
"""Slack integration credentials used by organization-level IM setup."""
|
||||
|
||||
provider: Literal[IMProvider.SLACK] = Field(description="Discriminator for Slack integration credentials.")
|
||||
client_id: str = Field(description="Slack OAuth client identifier.")
|
||||
client_secret: str | PreserveOriginalValue = Field(description="Slack OAuth client secret.")
|
||||
signing_secret: str | PreserveOriginalValue = Field(description="Slack signing secret used to verify callbacks.")
|
||||
bot_token: str | PreserveOriginalValue = Field(
|
||||
description="Slack bot token used for API calls and message delivery."
|
||||
)
|
||||
|
||||
|
||||
class DingTalkIMIntegrationCredentials(_RequestModel):
|
||||
"""DingTalk integration credentials used by organization-level IM setup."""
|
||||
|
||||
provider: Literal[IMProvider.DING_TALK] = Field(description="Discriminator for DingTalk integration credentials.")
|
||||
client_id: str = Field(description="DingTalk application client identifier.")
|
||||
client_secret: str | PreserveOriginalValue = Field(
|
||||
description="DingTalk application client secret. This field will be masked in response."
|
||||
)
|
||||
|
||||
|
||||
class MSTeamsIMIntegrationCredentials(_RequestModel):
|
||||
"""Microsoft Teams integration credentials used by organization-level IM setup."""
|
||||
|
||||
provider: Literal[IMProvider.MS_TEAMS] = Field(
|
||||
description="Discriminator for Microsoft Teams integration credentials."
|
||||
)
|
||||
tenant_id: str = Field(description="Microsoft Entra tenant identifier.")
|
||||
client_id: str = Field(description="Microsoft Teams application client identifier.")
|
||||
client_secret: str | PreserveOriginalValue = Field(
|
||||
description="Microsoft Teams application client secret. This field will be masked in response"
|
||||
)
|
||||
|
||||
|
||||
class WeComIMIntegrationCredentials(_RequestModel):
|
||||
"""WeCom integration credentials used by organization-level IM setup."""
|
||||
|
||||
provider: Literal[IMProvider.WE_COM] = Field(description="Discriminator for WeCom integration credentials.")
|
||||
corp_id: str = Field(description="WeCom corporation identifier.")
|
||||
agent_id: str = Field(description="WeCom agent identifier.")
|
||||
secret: str | PreserveOriginalValue = Field(
|
||||
description="WeCom application secret. This field will be masked in response"
|
||||
)
|
||||
|
||||
|
||||
IMIntegrationCredentials = Annotated[
|
||||
FeishuIMIntegrationCredentials
|
||||
| LarkIMIntegrationCredentials
|
||||
| SlackIMIntegrationCredentials
|
||||
| DingTalkIMIntegrationCredentials
|
||||
| MSTeamsIMIntegrationCredentials
|
||||
| WeComIMIntegrationCredentials,
|
||||
Field(discriminator="provider"),
|
||||
]
|
||||
|
||||
|
||||
class _IMIntegrationRequest(_RequestModel):
|
||||
"""Internal shared body for IM integration write/test operations."""
|
||||
|
||||
credentials: IMIntegrationCredentials = Field(description="Provider-specific IM integration credentials.")
|
||||
|
||||
|
||||
class UpdateIMIntegrationRequest(_IMIntegrationRequest):
|
||||
"""Request body for creating or updating one IM integration."""
|
||||
|
||||
expected_integration_id: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
description="Current integration identifier used with expected_config_version for compare-and-swap.",
|
||||
)
|
||||
expected_config_version: int | None = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
description="Current integration revision used with expected_integration_id for compare-and-swap.",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_complete_cas_token(self) -> Self:
|
||||
has_integration_id = self.expected_integration_id is not None
|
||||
has_config_version = self.expected_config_version is not None
|
||||
if has_integration_id != has_config_version:
|
||||
raise ValueError("expected_integration_id and expected_config_version must be provided together")
|
||||
return self
|
||||
|
||||
|
||||
class DeleteIMIntegrationQuery(_NoExtraModel):
|
||||
"""CAS token required when deleting the current IM integration."""
|
||||
|
||||
expected_integration_id: str = Field(min_length=1, description="Current integration identifier.")
|
||||
expected_config_version: int = Field(ge=1, description="Current integration revision.")
|
||||
|
||||
|
||||
class TestIMIntegrationRequest(_IMIntegrationRequest):
|
||||
"""Request body for testing one IM integration."""
|
||||
|
||||
|
||||
class IMIntegration(ResponseModel):
|
||||
"""One organization-level IM integration snapshot."""
|
||||
|
||||
provider: IMProvider | None = Field(
|
||||
default=None,
|
||||
description="Configured IM provider. None is allowed when the integration is not configured.",
|
||||
)
|
||||
status: IMIntegrationStatus = Field(description="Current integration connectivity state.")
|
||||
callback_url: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Callback URL expected by the provider. "
|
||||
"None if the current deployment uses persistence connections for receive events."
|
||||
),
|
||||
)
|
||||
permission_hint: str | None = Field(default=None, description="Operator-facing hint about permission issues.")
|
||||
configured_at: Timestamp | None = Field(
|
||||
default=None, description="Unix timestamp in milliseconds when the integration was created."
|
||||
)
|
||||
updated_at: Timestamp | None = Field(
|
||||
default=None, description="Unix timestamp in milliseconds when the integration was last updated."
|
||||
)
|
||||
integration_id: str | None = Field(
|
||||
default=None,
|
||||
description="Stable integration identifier. None when no integration is configured.",
|
||||
)
|
||||
config_version: int | None = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
description="Monotonic configuration revision. None when no integration is configured.",
|
||||
)
|
||||
|
||||
|
||||
class GetIMIntegrationResponse(ResponseModel):
|
||||
"""Response body carrying one IM integration snapshot."""
|
||||
|
||||
integration: IMIntegration = Field(description="Current organization-level IM integration snapshot.")
|
||||
|
||||
|
||||
class UpdateIMIntegrationResponse(ResponseModel):
|
||||
"""Response body returned after updating one IM integration."""
|
||||
|
||||
integration: IMIntegration = Field(description="Saved organization-level IM integration snapshot.")
|
||||
|
||||
|
||||
class TestIMIntegrationResponse(ResponseModel):
|
||||
"""Response body returned by IM integration test APIs."""
|
||||
|
||||
status: IMIntegrationStatus = Field(description="Integration status mapped from the test result.")
|
||||
message: str = Field(description="Human-readable explanation of the test result.")
|
||||
|
||||
|
||||
class IMSyncRunResultCounts(ResponseModel):
|
||||
"""Aggregate result counts for one IM sync run."""
|
||||
|
||||
added: int = Field(description="Number of entries newly matched and bound.")
|
||||
not_matched: int = Field(description="Number of entries that could not be matched.")
|
||||
failed: int = Field(description="Number of entries that failed to reconcile.")
|
||||
removed: int = Field(description="Number of entries whose prior binding was removed.")
|
||||
skipped: int = Field(description="Number of entries intentionally skipped.")
|
||||
|
||||
|
||||
class IMSyncRun(ResponseModel):
|
||||
"""One IM sync run snapshot.
|
||||
|
||||
The latest-only UI displays ``finished_at`` as the explicit sync time. The
|
||||
transport contract intentionally does not expose a ``started_by`` actor.
|
||||
"""
|
||||
|
||||
id: IMSyncRunId = Field(description="Unique sync run identifier.")
|
||||
status: IMSyncRunStatus = Field(description="Current lifecycle state of the sync run.")
|
||||
started_at: Timestamp | None = Field(
|
||||
default=None, description="Unix timestamp in milliseconds when the sync run started."
|
||||
)
|
||||
finished_at: Timestamp | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Unix timestamp in milliseconds when the sync run finished. "
|
||||
"This is the sync time displayed by the latest-only UI and is None while the run is unfinished."
|
||||
),
|
||||
)
|
||||
error_message: str | None = Field(
|
||||
default=None,
|
||||
description="Terminal error message. Present only when the sync run status is `failed`.",
|
||||
)
|
||||
result_counts: IMSyncRunResultCounts = Field(
|
||||
description="Aggregate reconciliation counts for the current run snapshot.",
|
||||
)
|
||||
provider: IMProvider = Field(description="IM provider associated with the sync run.")
|
||||
integration_id: str = Field(description="Integration identifier captured when the sync run was created.")
|
||||
integration_config_version: int = Field(
|
||||
ge=1,
|
||||
description="Integration configuration revision captured when the sync run was created.",
|
||||
)
|
||||
|
||||
|
||||
class CreateIMSyncRunResponse(ResponseModel):
|
||||
"""Response body returned after creating one sync run."""
|
||||
|
||||
run: IMSyncRun = Field(description="Newly created sync run snapshot.")
|
||||
|
||||
|
||||
class IMDirectoryEntry(_RequestModel):
|
||||
"""Normalized provider-side account observed during an IM sync run.
|
||||
|
||||
The entry is run-scoped input to identity and binding reconciliation. It does
|
||||
not represent a stable Dify identity and must not be referenced by bindings
|
||||
or runtime authorization. Sync results may retain a snapshot for display,
|
||||
diagnostics, and audit; durable references use IMIdentity or IMBinding IDs.
|
||||
"""
|
||||
|
||||
provider_user_id: str
|
||||
display_name: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class IMIdentitySnapshot(_RequestModel):
|
||||
"""Last known persistent IM identity state retained by a sync result."""
|
||||
|
||||
identity_id: IMIdentityId
|
||||
provider_user_id: str
|
||||
display_name: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class IMSyncResultAdded(BaseModel):
|
||||
type: Literal[IMSyncResultType.ADDED] = IMSyncResultType.ADDED
|
||||
|
||||
contact: HumanInputContactSummary = Field(description="The contact that associated with this sync result.")
|
||||
entry: IMDirectoryEntry = Field(description="Provider directory entry observed during the current sync run.")
|
||||
|
||||
|
||||
class IMSyncResultRemoved(BaseModel):
|
||||
type: Literal[IMSyncResultType.REMOVED] = IMSyncResultType.REMOVED
|
||||
|
||||
contact: HumanInputContactSummary = Field(description="The contact that associated with this sync result.")
|
||||
last_known_identity: IMIdentitySnapshot = Field(
|
||||
description="Last known persistent IM identity state before its binding was removed."
|
||||
)
|
||||
reason: IMSyncRemovalReason = Field(description="Reason the existing IM binding was removed.")
|
||||
|
||||
|
||||
class IMSyncResultFailed(BaseModel):
|
||||
type: Literal[IMSyncResultType.FAILED] = IMSyncResultType.FAILED
|
||||
|
||||
entry: IMDirectoryEntry | None = Field(
|
||||
None, description="Provider directory entry observed before this reconciliation failure, if available."
|
||||
)
|
||||
reason: str = Field(description="Reason the binding failed to sync.")
|
||||
|
||||
|
||||
class IMSyncResultSkipped(BaseModel):
|
||||
type: Literal[IMSyncResultType.SKIPPED] = IMSyncResultType.SKIPPED
|
||||
|
||||
entry: IMDirectoryEntry | None = Field(
|
||||
None, description="Provider directory entry observed before reconciliation was skipped, if available."
|
||||
)
|
||||
contact: HumanInputContactSummary = Field(description="The contact that associated with this sync result.")
|
||||
|
||||
|
||||
class IMSyncResultNotMatched(BaseModel):
|
||||
type: Literal[IMSyncResultType.NOT_MATCHED] = IMSyncResultType.NOT_MATCHED
|
||||
|
||||
entry: IMDirectoryEntry | None = Field(
|
||||
None, description="Provider directory entry that could not be matched, if available."
|
||||
)
|
||||
|
||||
|
||||
IMSyncResult = Annotated[
|
||||
Union[
|
||||
IMSyncResultAdded,
|
||||
IMSyncResultRemoved,
|
||||
IMSyncResultFailed,
|
||||
IMSyncResultNotMatched,
|
||||
IMSyncResultSkipped,
|
||||
],
|
||||
Discriminator("type"),
|
||||
]
|
||||
|
||||
|
||||
class IMSyncResultItem(ResponseModel):
|
||||
"""One paginated reconciliation result entry for the latest sync run."""
|
||||
|
||||
# The current implementation does not return IM binding status for other IM providers.
|
||||
# According to the design, we should return IM binding status for all configured IM providers.
|
||||
# However, this version allows only one configured IM provider, so this model excludes
|
||||
# the IM binding status for other IM providers.
|
||||
|
||||
id: str = Field(description="Unique synchronization result identifier.")
|
||||
result: IMSyncResult = Field(description="Result bucket this entry belongs to.")
|
||||
|
||||
|
||||
class GetLatestIMSyncRunResponse(ResponseModel):
|
||||
"""Response body for reading the latest IM sync run summary."""
|
||||
|
||||
run: IMSyncRun = Field(description="Latest sync run summary.")
|
||||
|
||||
|
||||
class ListLatestIMSyncRunResultsQuery(PaginationParamsMixin, _NoExtraModel):
|
||||
"""Query params for reading paginated latest-run results."""
|
||||
|
||||
result: IMSyncResultType = Field(
|
||||
...,
|
||||
description=(
|
||||
"Required result bucket to paginate from the latest sync run. "
|
||||
"There is no `all` bucket or unfiltered results mode."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ListLatestIMSyncRunResultsResponse(PaginationResultMixin, ResponseModel):
|
||||
"""Page-based latest-run results without cursor state or a repeated run summary."""
|
||||
|
||||
data: list[IMSyncResultItem] = Field(
|
||||
description="Result entries returned with page, limit, and total metadata for the selected bucket."
|
||||
)
|
||||
|
||||
|
||||
class ListIMIdentitiesQuery(PaginationParamsMixin, _NoExtraModel):
|
||||
"""Query params for searching synced IM identities."""
|
||||
|
||||
keyword: str | None = Field(
|
||||
default=None,
|
||||
description="Free-text search against identity display name, email, or provider user ID.",
|
||||
)
|
||||
|
||||
|
||||
class IMIdentity(ResponseModel):
|
||||
"""One synced IM identity that may be bound or overridden."""
|
||||
|
||||
id: IMIdentityId = Field(description="Internal IM identity record identifier.")
|
||||
provider: IMProvider = Field(description="IM provider that owns this identity.")
|
||||
provider_user_id: str = Field(description="Provider-side user identifier.")
|
||||
display_name: str | None = Field(default=None, description="Display name returned by the provider.")
|
||||
email: str | None = Field(default=None, description="Email returned by the provider, if any.")
|
||||
binding_status: IMIdentityBindingStatus = Field(
|
||||
description="Whether this IM identity is currently bound to a contact."
|
||||
)
|
||||
|
||||
|
||||
class ListIMIdentitiesResponse(PaginationResultMixin, ResponseModel):
|
||||
"""Paginated response body for synced IM identity search."""
|
||||
|
||||
data: list[IMIdentity] = Field(description="IM identities returned for the current page.")
|
||||
|
||||
|
||||
class SetContactIMOverrideRequest(_RequestModel):
|
||||
"""Request body for setting one workspace-scoped IM override."""
|
||||
|
||||
identity_id: IMIdentityId = Field(description="Synced IM identity identifier selected as the workspace override.")
|
||||
|
||||
|
||||
class SetContactIMOverrideResponse(ResponseModel):
|
||||
"""Response body returned after setting one contact IM override."""
|
||||
|
||||
contact: HumanInputContact = Field(description="Contact snapshot after the override is applied.")
|
||||
|
||||
|
||||
class ResetContactIMOverrideResponse(ResponseModel):
|
||||
"""Response body returned after resetting one contact IM override."""
|
||||
|
||||
contact: HumanInputContact = Field(description="Contact snapshot after the override is cleared.")
|
||||
|
||||
|
||||
class CreateIMBindingRequest(_RequestModel):
|
||||
"""Request body for setting one workspace-scoped IM override."""
|
||||
|
||||
identity_id: IMIdentityId = Field(description="Synced IM identity identifier selected as the workspace override.")
|
||||
|
||||
|
||||
class CreateIMBindingResponse(ResponseModel):
|
||||
"""Response body returned after binding one IM identity to the workspace."""
|
||||
|
||||
contact: HumanInputContact = Field(description="Contact snapshot after the IM identity is bound.")
|
||||
|
||||
|
||||
class DeleteIMBindingQuery(_RequestModel):
|
||||
binding_id: IMBindingId = Field(description="IM binding to unbind.")
|
||||
|
||||
|
||||
class DeleteIMBindingResponse(ResponseModel):
|
||||
pass
|
||||
|
||||
|
||||
class MessageTemplateTestRequest(_RequestModel):
|
||||
"""Request body for sending one message-template test notification."""
|
||||
|
||||
channel: Channel = Field(description="Target debug delivery channel used for the test send.")
|
||||
inputs: dict[str, JsonValue] = Field(
|
||||
default_factory=dict,
|
||||
description="Variable values used when rendering the message template preview.",
|
||||
)
|
||||
|
||||
|
||||
class MessageTemplateTestResponse(ResponseModel):
|
||||
"""Response body returned after one message-template test send."""
|
||||
|
||||
|
||||
class FormAccessRequestResponse(ResponseModel):
|
||||
"""Response body returned after creating one OTP challenge."""
|
||||
|
||||
expires_in_seconds: int = Field(description="Seconds until the current OTP challenge expires.")
|
||||
resend_after_seconds: int = Field(description="Seconds until another OTP challenge may be requested.")
|
||||
challenge_token: str = Field(description="The token used to complete the OTP challenge.")
|
||||
|
||||
|
||||
class FormDefinitionResponse(ResponseModel):
|
||||
"""Response body containing a resolved human-input form definition."""
|
||||
|
||||
form_content: str | None = Field(default=None, description="Rendered form body shown to the approver.")
|
||||
inputs: list[FormInputConfig] = Field(default_factory=list, description="Resolved form input definitions.")
|
||||
resolved_default_values: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="Default values after variable resolution and stringification.",
|
||||
)
|
||||
user_actions: list[UserActionConfig] = Field(
|
||||
default_factory=list,
|
||||
description="Action buttons that can complete the form.",
|
||||
)
|
||||
expiration_time: int = Field(description="Unix timestamp when the current form expires.")
|
||||
|
||||
|
||||
class ServiceFormQuery(_NoExtraModel):
|
||||
"""Query params for reading one service-api human-input form."""
|
||||
|
||||
user: str = Field(min_length=1, description="End-user identifier used to scope the service API request.")
|
||||
|
||||
|
||||
class BatchGetContactsQuery(_NoExtraModel):
|
||||
contact_ids: list[ContactId] = Field(..., description="List of contact IDs to retrieve.")
|
||||
|
||||
|
||||
class BatchGetContactsResponse(ResponseModel):
|
||||
data: list[HumanInputContactSummary] = Field(..., description="List of retrieved human input contacts.")
|
||||
|
||||
|
||||
class BatchGetContactOptionsQuery(_NoExtraModel):
|
||||
contact_ids: list[ContactId] = Field(..., description="Contact IDs persisted in workflow recipient configuration.")
|
||||
|
||||
|
||||
class BatchGetContactOptionsResponse(ResponseModel):
|
||||
data: list[ContactOption] = Field(..., description="Selectable contacts resolved in request order.")
|
||||
|
||||
|
||||
class HumanInputV2FormSubmitRequest(_RequestModel):
|
||||
"""Public Human Input v2 submit payload, independent from the v1 form contract."""
|
||||
|
||||
inputs: dict[str, JsonValue] = Field(description="Submitted form values keyed by output variable name.")
|
||||
action: str = Field(description="Identifier of the selected Human Input v2 action.")
|
||||
challenge_token: str | None = Field(
|
||||
default=None,
|
||||
description="OTP challenge token returned by the Human Input v2 access-request endpoint.",
|
||||
)
|
||||
otp_code: str | None = Field(
|
||||
default=None,
|
||||
description="OTP code required when the current Human Input v2 approver uses email proof.",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_complete_email_proof(self) -> Self:
|
||||
has_challenge_token = self.challenge_token is not None
|
||||
has_otp_code = self.otp_code is not None
|
||||
if has_challenge_token != has_otp_code:
|
||||
raise ValueError("challenge_token and otp_code must be provided together")
|
||||
return self
|
||||
|
||||
|
||||
class HumanInputV2ServiceFormSubmitRequest(_RequestModel):
|
||||
"""Trusted Service API submit payload without public-web OTP proof fields."""
|
||||
|
||||
inputs: dict[str, JsonValue] = Field(description="Submitted form values keyed by output variable name.")
|
||||
action: str = Field(description="Identifier of the selected Human Input v2 action.")
|
||||
user: str = Field(min_length=1, description="End-user identifier scoped to the current app token.")
|
||||
|
||||
|
||||
class FormUploadTokenResponse(ResponseModel):
|
||||
"""Response body returned when issuing a Human Input v2 upload token."""
|
||||
|
||||
upload_token: str
|
||||
expires_at: int
|
||||
|
||||
|
||||
class FormSubmitResponse(ResponseModel):
|
||||
"""Empty response body returned after a Human Input v2 form submission."""
|
||||
|
||||
|
||||
# =================== Node migration related entities ===================
|
||||
|
||||
|
||||
class LegacyHITLv1NodeData(HITLv1NodeData):
|
||||
"""Legacy Human Input node data accepted by the v1-to-v2 migration helper.
|
||||
|
||||
Missing versions use the historical v1 default. Any explicit value other
|
||||
than the string ``"1"`` is rejected before migration.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
# Keep the mutable parent field type for static substitutability while
|
||||
# preserving the literal transport schema and runtime validation.
|
||||
version: str = Field(
|
||||
default="1",
|
||||
description=(
|
||||
'Legacy Human Input node version. Missing values default to "1"; '
|
||||
'any explicit value other than the string "1" is rejected.'
|
||||
),
|
||||
json_schema_extra={"const": "1"},
|
||||
)
|
||||
|
||||
@field_validator("version")
|
||||
@classmethod
|
||||
def validate_version(cls, value: str) -> str:
|
||||
if value != "1":
|
||||
raise ValueError('version must be "1"')
|
||||
return value
|
||||
|
||||
|
||||
class NodeDataMigrationInput(_MigrationInputModel):
|
||||
"""One legacy node submitted through the frontend migration adapter boundary."""
|
||||
|
||||
node_id: str = Field(
|
||||
..., description="The identifier of node to migrate. Used to associate between request and response"
|
||||
)
|
||||
node_data: LegacyHITLv1NodeData = Field(..., description="The legacy Human Input node data to migrate.")
|
||||
|
||||
|
||||
class NodeDataMigrationPayload(_MigrationInputModel):
|
||||
"""Complete legacy-node batch submitted for one migration attempt."""
|
||||
|
||||
nodes: list[NodeDataMigrationInput] = Field(min_length=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_unique_node_ids(self) -> Self:
|
||||
node_ids = [node.node_id for node in self.nodes]
|
||||
if len(node_ids) != len(set(node_ids)):
|
||||
raise ValueError("node_id must be unique within one migration request")
|
||||
return self
|
||||
|
||||
|
||||
class NodeDataMigrationResult(ResponseModel):
|
||||
"""One converted node returned with its frontend correlation identifier."""
|
||||
|
||||
node_id: str = Field(description="The identifier of the migrated node.")
|
||||
node_data: HITLv2NodeData = Field(description="The complete converted Human Input v2 node data.")
|
||||
|
||||
|
||||
class NodeDataMigrationResponse(ResponseModel):
|
||||
"""Successful all-node conversion response."""
|
||||
|
||||
data: list[NodeDataMigrationResult]
|
||||
|
||||
|
||||
NodeDataMigrationBlockerCode = Literal[
|
||||
"unsupported-version",
|
||||
"configured-disabled-method",
|
||||
"unsupported-delivery-method",
|
||||
"invalid-email-configuration",
|
||||
"invalid-email",
|
||||
"unresolved-member",
|
||||
"conflicting-email-templates",
|
||||
"missing-recipients",
|
||||
]
|
||||
|
||||
|
||||
class NodeDataMigrationBlocker(ResponseModel):
|
||||
"""Stable node-scoped reason why the backend cannot produce lossless v2 data."""
|
||||
|
||||
node_id: str = Field(description="The identifier of the node that failed migration.")
|
||||
node_title: str = Field(description="The node title used for actionable frontend feedback.")
|
||||
code: NodeDataMigrationBlockerCode = Field(description="Machine-readable migration blocker code.")
|
||||
method_id: str | None = Field(default=None, description="Legacy delivery method related to the blocker.")
|
||||
value: str | None = Field(default=None, description="Safe legacy value related to the blocker.")
|
||||
|
||||
|
||||
class NodeDataMigrationFailureResponse(ResponseModel):
|
||||
"""Whole-batch failure response without partial converted node data."""
|
||||
|
||||
code: Literal["hitl_node_data_migration_failure"] = "hitl_node_data_migration_failure"
|
||||
message: str = Field(..., description="overall error messages")
|
||||
status: Literal[HTTPStatus.BAD_REQUEST] = HTTPStatus.BAD_REQUEST
|
||||
blockers: list[NodeDataMigrationBlocker] = Field(
|
||||
..., description="Node-scoped blockers that caused the whole batch to fail."
|
||||
)
|
||||
|
||||
|
||||
# =================== EmailProvider related entities ===================
|
||||
|
||||
|
||||
class PreserveOriginalValue(_RequestModel):
|
||||
tag: Literal["preserve_original_value"] = "preserve_original_value"
|
||||
|
||||
|
||||
class ResendProviderUpdateConfig(_RequestModel):
|
||||
type: Literal[EmailProviderType.RESEND] = EmailProviderType.RESEND
|
||||
|
||||
api_key: str | PreserveOriginalValue = Field(
|
||||
...,
|
||||
description=(
|
||||
"Resend API key. "
|
||||
"Setting this to `PreserveOriginalValue` while updating will preserve the previously set credential."
|
||||
),
|
||||
)
|
||||
sender_email: str = Field(
|
||||
..., description="The email address shown as the sender. Its domain must be verified in Resend."
|
||||
)
|
||||
|
||||
sender_name: str = Field("", description="The sender's name displayed in the recipient's inbox.")
|
||||
|
||||
|
||||
class ResendProviderConfigResponse(ResponseModel):
|
||||
type: Literal[EmailProviderType.RESEND] = EmailProviderType.RESEND
|
||||
api_key_configured: bool = Field(description="Whether a Resend API key has been configured.")
|
||||
sender_email: str = Field(description="The email address shown as the sender.")
|
||||
sender_name: str = Field("", description="The sender's name displayed in the recipient's inbox.")
|
||||
|
||||
|
||||
EmailProviderUpdateConfig = ResendProviderUpdateConfig
|
||||
EmailProviderConfigResponse = ResendProviderConfigResponse
|
||||
|
||||
|
||||
class GetEmailProviderResponse(ResponseModel):
|
||||
provider_config: EmailProviderConfigResponse | None = Field(
|
||||
...,
|
||||
description="The current email provider configuration. `None` if not set.",
|
||||
)
|
||||
|
||||
|
||||
class SetEmailProviderRequest(_RequestModel):
|
||||
provider_config: EmailProviderUpdateConfig = Field(..., description="Email provider configuration update.")
|
||||
|
||||
|
||||
class SetEmailProviderResponse(ResponseModel):
|
||||
pass
|
||||
|
||||
|
||||
class TestEmailProviderConfigRequest(_RequestModel):
|
||||
pass
|
||||
|
||||
|
||||
class TestEmailProviderConfigResponse(ResponseModel):
|
||||
pass
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AddPlatformContactsRequest",
|
||||
"AddPlatformContactsResponse",
|
||||
"BatchGetContactOptionsQuery",
|
||||
"BatchGetContactOptionsResponse",
|
||||
"ContactListQuery",
|
||||
"ContactOption",
|
||||
"ContactOptionsQuery",
|
||||
"CreateIMSyncRunResponse",
|
||||
"DeleteIMIntegrationQuery",
|
||||
"DingTalkIMIntegrationCredentials",
|
||||
"EmailProviderConfigResponse",
|
||||
"EmailProviderType",
|
||||
"EmailProviderUpdateConfig",
|
||||
"ExternalContactCreateRequest",
|
||||
"ExternalContactUpdateRequest",
|
||||
"FeishuIMIntegrationCredentials",
|
||||
"FormAccessRequestResponse",
|
||||
"FormDefinitionResponse",
|
||||
"FormSubmitResponse",
|
||||
"FormUploadTokenResponse",
|
||||
"GetContactResponse",
|
||||
"GetEmailProviderResponse",
|
||||
"GetIMIntegrationResponse",
|
||||
"GetLatestIMSyncRunResponse",
|
||||
"HumanInputContact",
|
||||
"HumanInputContactType",
|
||||
"HumanInputV2FormSubmitRequest",
|
||||
"HumanInputV2ServiceFormSubmitRequest",
|
||||
"IMIdentity",
|
||||
"IMIdentityBindingStatus",
|
||||
"IMIntegration",
|
||||
"IMIntegrationCredentials",
|
||||
"IMIntegrationStatus",
|
||||
"IMProvider",
|
||||
"IMSyncRemovalReason",
|
||||
"IMSyncResultItem",
|
||||
"IMSyncResultType",
|
||||
"IMSyncRun",
|
||||
"IMSyncRunResultCounts",
|
||||
"IMSyncRunStatus",
|
||||
"LarkIMIntegrationCredentials",
|
||||
"ListContactOptionsResponse",
|
||||
"ListContactsResponse",
|
||||
"ListIMIdentitiesQuery",
|
||||
"ListIMIdentitiesResponse",
|
||||
"ListLatestIMSyncRunResultsQuery",
|
||||
"ListLatestIMSyncRunResultsResponse",
|
||||
"ListOrganizationCandidatesResponse",
|
||||
"MSTeamsIMIntegrationCredentials",
|
||||
"MessageTemplateTestRequest",
|
||||
"MessageTemplateTestResponse",
|
||||
"NodeDataMigrationFailureResponse",
|
||||
"NodeDataMigrationPayload",
|
||||
"NodeDataMigrationResponse",
|
||||
"OrganizationCandidate",
|
||||
"OrganizationCandidatesQuery",
|
||||
"PreserveOriginalValue",
|
||||
"RemoveContactsRequest",
|
||||
"RemoveContactsResponse",
|
||||
"ResendProviderConfigResponse",
|
||||
"ResendProviderUpdateConfig",
|
||||
"ResetContactIMOverrideResponse",
|
||||
"ServiceFormQuery",
|
||||
"SetContactIMOverrideRequest",
|
||||
"SetContactIMOverrideResponse",
|
||||
"SetEmailProviderRequest",
|
||||
"SetEmailProviderResponse",
|
||||
"SlackIMIntegrationCredentials",
|
||||
"TestIMIntegrationRequest",
|
||||
"TestIMIntegrationResponse",
|
||||
"UpdateIMIntegrationRequest",
|
||||
"UpdateIMIntegrationResponse",
|
||||
"WeComIMIntegrationCredentials",
|
||||
]
|
||||
@@ -52,7 +52,7 @@ def with_session[T, **P, R](
|
||||
session.commit()
|
||||
return result
|
||||
except Exception:
|
||||
session.rollback() # noqa: no-new-controller-sqlalchemy decorator owns transaction rollback
|
||||
session.rollback() # guard-ignore: no-new-controller-sqlalchemy -- decorator owns rollback
|
||||
raise
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
|
||||
@@ -76,7 +76,6 @@ from .app import (
|
||||
workflow_app_log,
|
||||
workflow_comment,
|
||||
workflow_draft_variable,
|
||||
workflow_human_input_v2,
|
||||
workflow_node_output_inspector,
|
||||
workflow_run,
|
||||
workflow_statistic,
|
||||
@@ -139,7 +138,6 @@ from .workspace import (
|
||||
account,
|
||||
agent_providers,
|
||||
endpoint,
|
||||
human_input,
|
||||
load_balancing_config,
|
||||
members,
|
||||
model_providers,
|
||||
@@ -196,7 +194,6 @@ __all__ = [
|
||||
"forgot_password",
|
||||
"generator",
|
||||
"hit_testing",
|
||||
"human_input",
|
||||
"human_input_form",
|
||||
"init_validate",
|
||||
"installed_app",
|
||||
@@ -244,7 +241,6 @@ __all__ = [
|
||||
"workflow_app_log",
|
||||
"workflow_comment",
|
||||
"workflow_draft_variable",
|
||||
"workflow_human_input_v2",
|
||||
"workflow_node_output_inspector",
|
||||
"workflow_run",
|
||||
"workflow_run_archive",
|
||||
|
||||
@@ -62,7 +62,7 @@ from libs.datetime_utils import parse_time_range
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.agent import Agent, AgentConfigDraftType, AgentStatus
|
||||
from models.agent import Agent, AgentStatus
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken, App, IconType
|
||||
@@ -265,13 +265,6 @@ class AgentDebugConversationRefreshResponse(BaseModel):
|
||||
debug_conversation_message_count: int = 0
|
||||
|
||||
|
||||
class AgentDebugConversationRefreshPayload(BaseModel):
|
||||
draft_type: AgentConfigDraftType = Field(
|
||||
default=AgentConfigDraftType.DEBUG_BUILD,
|
||||
description="Agent draft surface whose conversation should be refreshed",
|
||||
)
|
||||
|
||||
|
||||
class AgentPublishPayload(BaseModel):
|
||||
version_note: str | None = Field(default=None, description="Optional note for this published Agent version")
|
||||
|
||||
@@ -315,7 +308,6 @@ register_schema_models(
|
||||
AgentAppCopyPayload,
|
||||
AgentPublishPayload,
|
||||
AgentBuildDraftCheckoutPayload,
|
||||
AgentDebugConversationRefreshPayload,
|
||||
ComposerSavePayload,
|
||||
AgentApiStatusPayload,
|
||||
AgentInviteOptionsQuery,
|
||||
@@ -395,11 +387,10 @@ def _serialize_agent_app_detail(
|
||||
payload["backing_app_id"] = roster_service.runtime_backing_app_id(agent)
|
||||
payload["hidden_app_backed"] = bool(agent.backing_app_id and agent.backing_app_id != agent.app_id)
|
||||
payload["id"] = agent.id
|
||||
debug_conversation_id = roster_service.get_or_create_agent_app_debug_conversation_id(
|
||||
debug_conversation_id = roster_service.get_or_create_build_conversation(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent.id,
|
||||
account_id=current_user.id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
commit=False,
|
||||
)
|
||||
message_count = roster_service.count_agent_app_debug_conversation_messages(
|
||||
@@ -439,11 +430,10 @@ def _serialize_agent_app_pagination(session: Session, app_pagination, *, tenant_
|
||||
tenant_id=tenant_id,
|
||||
agent_ids=[agent.id for agent in agents_by_app_id.values()],
|
||||
)
|
||||
debug_conversation_ids_by_agent_id = roster_service.load_or_create_agent_app_debug_conversation_ids_by_agent_id(
|
||||
debug_conversation_ids_by_agent_id = roster_service.load_or_create_build_conversation_ids_by_agent_id(
|
||||
tenant_id=tenant_id,
|
||||
agents=list(agents_by_app_id.values()),
|
||||
account_id=current_user.id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
)
|
||||
payload = AgentAppPagination.model_validate(
|
||||
app_pagination,
|
||||
@@ -678,16 +668,6 @@ class AgentAppApi(Resource):
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/debug-conversation/refresh")
|
||||
class AgentDebugConversationRefreshApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AgentDebugConversationRefreshPayload.__name__])
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"payload": {
|
||||
"in": "body",
|
||||
"required": False,
|
||||
"schema": {"$ref": f"#/components/schemas/{AgentDebugConversationRefreshPayload.__name__}"},
|
||||
}
|
||||
}
|
||||
)
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Agent debug conversation refreshed",
|
||||
@@ -702,12 +682,10 @@ class AgentDebugConversationRefreshApi(Resource):
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
args = AgentDebugConversationRefreshPayload.model_validate(request.get_json(silent=True) or {})
|
||||
debug_conversation_id = _agent_roster_service(session).refresh_agent_app_debug_conversation_id(
|
||||
debug_conversation_id = _agent_roster_service(session).reset_build_conversation(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
draft_type=args.draft_type,
|
||||
)
|
||||
return AgentDebugConversationRefreshResponse(
|
||||
debug_conversation_id=debug_conversation_id,
|
||||
@@ -751,7 +729,7 @@ class AgentBuildDraftCheckoutApi(Resource):
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@with_session(write=False)
|
||||
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
args = AgentBuildDraftCheckoutPayload.model_validate(console_ns.payload or {})
|
||||
return AgentComposerService.checkout_agent_app_build_draft(
|
||||
@@ -808,7 +786,7 @@ class AgentBuildDraftApi(Resource):
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@with_session(write=False)
|
||||
def delete(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
return AgentComposerService.discard_agent_app_build_draft(
|
||||
session=session,
|
||||
@@ -828,7 +806,7 @@ class AgentBuildDraftApplyApi(Resource):
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@with_session(write=False)
|
||||
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
return AgentComposerService.apply_agent_app_build_draft(
|
||||
session=session,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Console routes for Agent App and workflow Agent sandbox file access.
|
||||
|
||||
The API keeps product-facing locators (conversation or workflow node identity)
|
||||
on this public boundary and proxies list/read/upload to the agent backend's new
|
||||
The API accepts product-facing Conversation, Build Draft, or Workflow Node
|
||||
Execution locators and proxies list/read/upload to the agent backend's
|
||||
``/sandbox`` contract.
|
||||
"""
|
||||
|
||||
@@ -26,10 +26,16 @@ from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.model import App, AppMode
|
||||
from services.agent_app_sandbox_service import (
|
||||
AgentAppSandboxService,
|
||||
@@ -37,52 +43,43 @@ from services.agent_app_sandbox_service import (
|
||||
WorkflowAgentSandboxService,
|
||||
)
|
||||
|
||||
_NODE_EXECUTION_ID_DESCRIPTION = (
|
||||
"Optional workflow node execution ID. When omitted, the latest active session for the node is used."
|
||||
)
|
||||
|
||||
|
||||
class AgentSandboxListQuery(BaseModel):
|
||||
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
|
||||
caller_type: Literal["conversation", "build_draft"]
|
||||
caller_id: str = Field(min_length=1, description="Agent App caller ID")
|
||||
path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
|
||||
|
||||
|
||||
class AgentSandboxInfoQuery(BaseModel):
|
||||
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
|
||||
caller_type: Literal["conversation", "build_draft"]
|
||||
caller_id: str = Field(min_length=1, description="Agent App caller ID")
|
||||
|
||||
|
||||
class AgentSandboxFileQuery(BaseModel):
|
||||
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
|
||||
caller_type: Literal["conversation", "build_draft"]
|
||||
caller_id: str = Field(min_length=1, description="Agent App caller ID")
|
||||
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
|
||||
|
||||
|
||||
class AgentSandboxUploadPayload(BaseModel):
|
||||
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
|
||||
caller_type: Literal["conversation", "build_draft"]
|
||||
caller_id: str = Field(min_length=1, description="Agent App caller ID")
|
||||
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
|
||||
|
||||
|
||||
class WorkflowAgentSandboxListQuery(BaseModel):
|
||||
node_execution_id: str = Field(min_length=1, description="Workflow node execution ID")
|
||||
path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
|
||||
node_execution_id: str | None = Field(
|
||||
default=None,
|
||||
description=_NODE_EXECUTION_ID_DESCRIPTION,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowAgentSandboxFileQuery(BaseModel):
|
||||
node_execution_id: str = Field(min_length=1, description="Workflow node execution ID")
|
||||
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
|
||||
node_execution_id: str | None = Field(
|
||||
default=None,
|
||||
description=_NODE_EXECUTION_ID_DESCRIPTION,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowAgentSandboxUploadPayload(BaseModel):
|
||||
node_execution_id: str = Field(min_length=1, description="Workflow node execution ID")
|
||||
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
|
||||
node_execution_id: str | None = Field(
|
||||
default=None,
|
||||
description=_NODE_EXECUTION_ID_DESCRIPTION,
|
||||
)
|
||||
|
||||
|
||||
class SandboxFileEntryResponse(ResponseModel):
|
||||
@@ -99,7 +96,6 @@ class SandboxListResponse(ResponseModel):
|
||||
|
||||
|
||||
class SandboxInfoResponse(ResponseModel):
|
||||
session_id: str
|
||||
workspace_cwd: str
|
||||
|
||||
|
||||
@@ -155,15 +151,19 @@ class AgentAppSandboxInfoResource(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_current_user
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
def get(self, session: Session, current_user: Account, tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
query = query_params_from_request(AgentSandboxInfoQuery)
|
||||
try:
|
||||
result = AgentAppSandboxService().get_info(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
conversation_id=query.conversation_id,
|
||||
agent_id=str(agent_id),
|
||||
caller_type=query.caller_type,
|
||||
caller_id=query.caller_id,
|
||||
account_id=current_user.id,
|
||||
)
|
||||
except Exception as exc:
|
||||
return _handle(exc)
|
||||
@@ -180,15 +180,19 @@ class AgentAppSandboxListResource(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_current_user
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
def get(self, session: Session, current_user: Account, tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
query = query_params_from_request(AgentSandboxListQuery)
|
||||
try:
|
||||
result = AgentAppSandboxService().list_files(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
conversation_id=query.conversation_id,
|
||||
agent_id=str(agent_id),
|
||||
caller_type=query.caller_type,
|
||||
caller_id=query.caller_id,
|
||||
account_id=current_user.id,
|
||||
path=query.path,
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -206,15 +210,19 @@ class AgentAppSandboxReadResource(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_current_user
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
def get(self, session: Session, current_user: Account, tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
query = query_params_from_request(AgentSandboxFileQuery)
|
||||
try:
|
||||
result = AgentAppSandboxService().read_file(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
conversation_id=query.conversation_id,
|
||||
agent_id=str(agent_id),
|
||||
caller_type=query.caller_type,
|
||||
caller_id=query.caller_id,
|
||||
account_id=current_user.id,
|
||||
path=query.path,
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -232,15 +240,19 @@ class AgentAppSandboxUploadResource(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_current_user
|
||||
@with_session(write=False)
|
||||
def post(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
def post(self, session: Session, current_user: Account, tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
payload = AgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {})
|
||||
try:
|
||||
result = AgentAppSandboxService().upload_file(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
conversation_id=payload.conversation_id,
|
||||
agent_id=str(agent_id),
|
||||
caller_type=payload.caller_type,
|
||||
caller_id=payload.caller_id,
|
||||
account_id=current_user.id,
|
||||
path=payload.path,
|
||||
)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -36,7 +36,7 @@ from controllers.console.wraps import (
|
||||
with_current_user_id,
|
||||
)
|
||||
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
||||
from core.app.entities.app_invoke_entities import AGENT_RUNTIME_EXIT_INTENT_ARG, InvokeFrom
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.app.features.rate_limiting.rate_limit import RateLimitGenerator
|
||||
from core.errors.error import (
|
||||
ModelCurrentlyNotSupportError,
|
||||
@@ -353,12 +353,7 @@ def _resolve_current_user_agent_debug_conversation_id(
|
||||
draft_type: AgentConfigDraftType,
|
||||
start_new: bool = False,
|
||||
) -> str:
|
||||
"""Resolve or rotate the current editor's conversation within one draft surface.
|
||||
|
||||
``start_new`` rotates the scoped mapping through ``AgentRosterService`` so
|
||||
the old runtime session is retired before the new conversation is used.
|
||||
Continuations and Build chat keep resolving the existing mapping.
|
||||
"""
|
||||
"""Resolve the current editor's Build or Preview conversation."""
|
||||
|
||||
roster_service = AgentRosterService(session)
|
||||
resolved_agent_id = agent_id
|
||||
@@ -368,17 +363,26 @@ def _resolve_current_user_agent_debug_conversation_id(
|
||||
raise AgentNotFoundError()
|
||||
resolved_agent_id = agent.id
|
||||
|
||||
resolve_conversation = (
|
||||
roster_service.refresh_agent_app_debug_conversation_id
|
||||
if start_new
|
||||
else roster_service.get_or_create_agent_app_debug_conversation_id
|
||||
)
|
||||
return resolve_conversation(
|
||||
if draft_type == AgentConfigDraftType.DEBUG_BUILD:
|
||||
return roster_service.get_or_create_build_conversation(
|
||||
tenant_id=current_tenant_id,
|
||||
agent_id=resolved_agent_id,
|
||||
account_id=current_user.id,
|
||||
)
|
||||
if start_new:
|
||||
return roster_service.rotate_preview_conversation(
|
||||
tenant_id=current_tenant_id,
|
||||
agent_id=resolved_agent_id,
|
||||
account_id=current_user.id,
|
||||
)
|
||||
conversation_id = roster_service.get_current_preview_conversation(
|
||||
tenant_id=current_tenant_id,
|
||||
agent_id=resolved_agent_id,
|
||||
account_id=current_user.id,
|
||||
draft_type=draft_type,
|
||||
)
|
||||
if conversation_id is None:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
return conversation_id
|
||||
|
||||
|
||||
def _create_chat_message(
|
||||
@@ -450,7 +454,6 @@ def _create_build_chat_finalization_message(
|
||||
"draft_type": "debug_build",
|
||||
"conversation_id": debug_conversation_id,
|
||||
"auto_generate_name": False,
|
||||
AGENT_RUNTIME_EXIT_INTENT_ARG: "delete",
|
||||
}
|
||||
external_trace_id = get_external_trace_id(request)
|
||||
if external_trace_id:
|
||||
|
||||
@@ -76,11 +76,13 @@ from models import Account, App
|
||||
from models.model import AppMode
|
||||
from models.workflow import Workflow
|
||||
from repositories.workflow_collaboration_repository import WORKFLOW_ONLINE_USERS_PREFIX
|
||||
from services.agent.retirement_service import WorkflowAgentRetirementService
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
from services.workflow_ref_service import WorkflowRefService
|
||||
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
|
||||
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -317,7 +319,7 @@ class _WorkflowResponseSource:
|
||||
self._session = session
|
||||
|
||||
def __getattr__(self, name: str) -> object:
|
||||
return getattr(self._workflow, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self._workflow, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
@property
|
||||
def created_by_account(self) -> Account | None:
|
||||
@@ -1245,7 +1247,7 @@ class PublishedWorkflowApi(Resource):
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
workflow = workflow_service.publish_workflow(
|
||||
workflow, retirement_candidates = workflow_service.publish_workflow(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
account=current_user,
|
||||
@@ -1262,6 +1264,16 @@ class PublishedWorkflowApi(Resource):
|
||||
|
||||
workflow_created_at = TimestampField().format(workflow.created_at)
|
||||
|
||||
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_ids=retirement_candidates,
|
||||
account_id=current_user.id,
|
||||
)
|
||||
enqueue_agent_resource_collection(
|
||||
tenant_id=app_model.tenant_id,
|
||||
binding_ids=binding_ids,
|
||||
home_snapshot_ids=home_snapshot_ids,
|
||||
)
|
||||
return {
|
||||
"result": "success",
|
||||
"created_at": workflow_created_at,
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
"""Draft Human Input v2 workflow controller stubs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
|
||||
from flask import abort
|
||||
from flask_restx import Resource
|
||||
|
||||
from controllers.common.human_input_v2_contracts import (
|
||||
MessageTemplateTestRequest,
|
||||
MessageTemplateTestResponse,
|
||||
)
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_user,
|
||||
)
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.model import AppMode
|
||||
|
||||
from .wraps import get_app_model
|
||||
|
||||
register_schema_models(console_ns, MessageTemplateTestRequest)
|
||||
register_response_schema_models(console_ns, MessageTemplateTestResponse)
|
||||
|
||||
|
||||
def _raise_stub_not_implemented() -> None:
|
||||
abort(HTTPStatus.NOT_IMPLEMENTED, "Human Input v2 draft stub endpoint is not implemented yet.")
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/human-input/nodes/<string:node_id>/message-template/test")
|
||||
class WorkflowDraftMessageTemplateTestApi(Resource):
|
||||
@console_ns.expect(console_ns.models[MessageTemplateTestRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[MessageTemplateTestResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
|
||||
@get_app_model(mode=[AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model, node_id: str):
|
||||
MessageTemplateTestRequest.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
"/apps/<uuid:app_id>/advanced-chat/workflows/draft/human-input/nodes/<string:node_id>/message-template/test"
|
||||
)
|
||||
class AdvancedChatDraftMessageTemplateTestApi(Resource):
|
||||
@console_ns.expect(console_ns.models[MessageTemplateTestRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[MessageTemplateTestResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model, node_id: str):
|
||||
MessageTemplateTestRequest.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
@@ -218,7 +218,7 @@ class _DatasetQueryResponseSource:
|
||||
return self.query.get_queries(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.query, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.query, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
class DatasetQueryListResponse(ResponseModel):
|
||||
@@ -257,7 +257,7 @@ class _RelatedAppResponseSource:
|
||||
return self.app.mode_compatible_with_agent_with_session(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.app, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.app, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
class RelatedAppListResponse(ResponseModel):
|
||||
|
||||
@@ -106,7 +106,7 @@ class ExternalKnowledgeApiResponseSource:
|
||||
return self.external_knowledge_api.get_dataset_bindings(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.external_knowledge_api, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.external_knowledge_api, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
def external_knowledge_api_response(
|
||||
|
||||
@@ -404,7 +404,7 @@ class TrialWorkflowResponseSource:
|
||||
return self.workflow.get_tool_published(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.workflow, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.workflow, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
register_schema_models(
|
||||
|
||||
@@ -56,10 +56,12 @@ from libs.helper import TimestampField
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models import Account
|
||||
from models.snippet import CustomizedSnippet
|
||||
from services.agent.retirement_service import WorkflowAgentRetirementService
|
||||
from services.agent.workflow_publish_service import WorkflowAgentPublishService
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
|
||||
from services.snippet_generate_service import SnippetGenerateService
|
||||
from services.snippet_service import SnippetService
|
||||
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -295,8 +297,9 @@ class SnippetPublishedWorkflowApi(Resource):
|
||||
|
||||
with Session(db.engine) as session:
|
||||
snippet = session.merge(snippet)
|
||||
tenant_id = snippet.tenant_id
|
||||
try:
|
||||
workflow = snippet_service.publish_workflow(
|
||||
workflow, retirement_candidates = snippet_service.publish_workflow(
|
||||
session=session,
|
||||
snippet=snippet,
|
||||
account=current_user,
|
||||
@@ -306,6 +309,16 @@ class SnippetPublishedWorkflowApi(Resource):
|
||||
except ValueError as e:
|
||||
return {"message": str(e)}, 400
|
||||
|
||||
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=tenant_id,
|
||||
agent_ids=retirement_candidates,
|
||||
account_id=current_user.id,
|
||||
)
|
||||
enqueue_agent_resource_collection(
|
||||
tenant_id=tenant_id,
|
||||
binding_ids=binding_ids,
|
||||
home_snapshot_ids=home_snapshot_ids,
|
||||
)
|
||||
return {
|
||||
"result": "success",
|
||||
"created_at": workflow_created_at,
|
||||
|
||||
@@ -1,541 +0,0 @@
|
||||
"""Workspace-level Human Input v2 controller stubs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
|
||||
from flask import abort, request
|
||||
from flask_restx import Resource
|
||||
|
||||
from controllers.common.human_input_v2_contracts import (
|
||||
AddPlatformContactsRequest,
|
||||
AddPlatformContactsResponse,
|
||||
BatchGetContactOptionsQuery,
|
||||
BatchGetContactOptionsResponse,
|
||||
BatchGetContactsQuery,
|
||||
BatchGetContactsResponse,
|
||||
ContactListQuery,
|
||||
ContactOption,
|
||||
ContactOptionsQuery,
|
||||
CreateIMBindingRequest,
|
||||
CreateIMBindingResponse,
|
||||
CreateIMSyncRunResponse,
|
||||
DeleteIMBindingQuery,
|
||||
DeleteIMBindingResponse,
|
||||
DeleteIMIntegrationQuery,
|
||||
ExternalContactCreateRequest,
|
||||
ExternalContactCreateResponse,
|
||||
ExternalContactUpdateRequest,
|
||||
ExternalContactUpdateResponse,
|
||||
GetContactResponse,
|
||||
GetEmailProviderResponse,
|
||||
GetIMIntegrationResponse,
|
||||
GetLatestIMSyncRunResponse,
|
||||
HumanInputContact,
|
||||
HumanInputContactType,
|
||||
IMIntegrationStatus,
|
||||
IMProvider,
|
||||
IMSyncResultType,
|
||||
IMSyncRunStatus,
|
||||
ListContactOptionsResponse,
|
||||
ListContactsResponse,
|
||||
ListIMIdentitiesQuery,
|
||||
ListIMIdentitiesResponse,
|
||||
ListLatestIMSyncRunResultsQuery,
|
||||
ListLatestIMSyncRunResultsResponse,
|
||||
ListOrganizationCandidatesResponse,
|
||||
NodeDataMigrationFailureResponse,
|
||||
NodeDataMigrationPayload,
|
||||
NodeDataMigrationResponse,
|
||||
OrganizationCandidatesQuery,
|
||||
RemoveContactsRequest,
|
||||
RemoveContactsResponse,
|
||||
ResetContactIMOverrideResponse,
|
||||
SetContactIMOverrideRequest,
|
||||
SetContactIMOverrideResponse,
|
||||
SetEmailProviderRequest,
|
||||
SetEmailProviderResponse,
|
||||
TestIMIntegrationRequest,
|
||||
TestIMIntegrationResponse,
|
||||
UpdateIMIntegrationRequest,
|
||||
UpdateIMIntegrationResponse,
|
||||
)
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
query_params_from_request,
|
||||
register_enum_models,
|
||||
register_response_schema_models,
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
is_admin_or_owner_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
)
|
||||
from libs.login import login_required
|
||||
|
||||
register_enum_models(
|
||||
console_ns,
|
||||
HumanInputContactType,
|
||||
IMIntegrationStatus,
|
||||
IMSyncRunStatus,
|
||||
IMSyncResultType,
|
||||
IMProvider,
|
||||
)
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
ContactListQuery,
|
||||
ContactOptionsQuery,
|
||||
BatchGetContactOptionsQuery,
|
||||
OrganizationCandidatesQuery,
|
||||
AddPlatformContactsRequest,
|
||||
ExternalContactCreateRequest,
|
||||
ExternalContactUpdateRequest,
|
||||
RemoveContactsRequest,
|
||||
UpdateIMIntegrationRequest,
|
||||
DeleteIMIntegrationQuery,
|
||||
TestIMIntegrationRequest,
|
||||
ListIMIdentitiesQuery,
|
||||
ListLatestIMSyncRunResultsQuery,
|
||||
SetContactIMOverrideRequest,
|
||||
CreateIMBindingRequest,
|
||||
NodeDataMigrationPayload,
|
||||
SetEmailProviderRequest,
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
HumanInputContact,
|
||||
ContactOption,
|
||||
GetContactResponse,
|
||||
ExternalContactCreateResponse,
|
||||
ExternalContactUpdateResponse,
|
||||
AddPlatformContactsResponse,
|
||||
ListContactsResponse,
|
||||
ListContactOptionsResponse,
|
||||
BatchGetContactOptionsResponse,
|
||||
RemoveContactsResponse,
|
||||
ListIMIdentitiesResponse,
|
||||
GetIMIntegrationResponse,
|
||||
UpdateIMIntegrationResponse,
|
||||
TestIMIntegrationResponse,
|
||||
CreateIMSyncRunResponse,
|
||||
GetLatestIMSyncRunResponse,
|
||||
ListLatestIMSyncRunResultsResponse,
|
||||
ListOrganizationCandidatesResponse,
|
||||
ResetContactIMOverrideResponse,
|
||||
SetContactIMOverrideResponse,
|
||||
CreateIMBindingResponse,
|
||||
DeleteIMBindingResponse,
|
||||
BatchGetContactsResponse,
|
||||
NodeDataMigrationResponse,
|
||||
NodeDataMigrationFailureResponse,
|
||||
GetEmailProviderResponse,
|
||||
SetEmailProviderResponse,
|
||||
)
|
||||
|
||||
|
||||
def _raise_stub_not_implemented() -> None:
|
||||
abort(HTTPStatus.NOT_IMPLEMENTED, "Human Input v2 stub endpoint is not implemented yet.")
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/contacts")
|
||||
class WorkspaceContactsApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(ContactListQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[ListContactsResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
ContactListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/contacts/<uuid:contact_id>")
|
||||
class WorkspaceContactApi(Resource):
|
||||
"""Read one contact only when it resolves in the current workspace scope."""
|
||||
|
||||
@console_ns.response(200, "Success", console_ns.models[GetContactResponse.__name__])
|
||||
@console_ns.response(404, "Contact not found or absent in the current workspace")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, contact_id: str):
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/contact-options")
|
||||
class WorkspaceContactOptionsApi(Resource):
|
||||
"""Search the current workspace's selectable Contact projection for workflow editors."""
|
||||
|
||||
@console_ns.doc(
|
||||
params=query_params_from_model(ContactOptionsQuery),
|
||||
description=(
|
||||
"List editor-safe Contact options for static recipient selection. "
|
||||
"The projection omits email, IM bindings, and management metadata; contacts that resolve as ABSENT "
|
||||
"or are otherwise unavailable in the current workspace are omitted."
|
||||
),
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[ListContactOptionsResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
ContactOptionsQuery.model_validate(request.args.to_dict(flat=True))
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/organization-candidates")
|
||||
class WorkspaceOrganizationCandidatesApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(OrganizationCandidatesQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[ListOrganizationCandidatesResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
OrganizationCandidatesQuery.model_validate(request.args.to_dict(flat=True))
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/contacts/platform")
|
||||
class WorkspacePlatformContactsApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AddPlatformContactsRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[AddPlatformContactsResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
AddPlatformContactsRequest.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/contacts/external")
|
||||
class WorkspaceExternalContactsApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ExternalContactCreateRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[ExternalContactCreateResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
ExternalContactCreateRequest.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/contacts/external/<uuid:contact_id>")
|
||||
class WorkspaceExternalContactApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ExternalContactUpdateRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[ExternalContactUpdateResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def patch(self, tenant_id: str, contact_id: str):
|
||||
ExternalContactUpdateRequest.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/contacts/remove")
|
||||
class WorkspaceContactsRemoveApi(Resource):
|
||||
@console_ns.expect(console_ns.models[RemoveContactsRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[RemoveContactsResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
RemoveContactsRequest.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/im-integration")
|
||||
class WorkspaceIMIntegrationApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[GetIMIntegrationResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
@console_ns.expect(console_ns.models[UpdateIMIntegrationRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[UpdateIMIntegrationResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str):
|
||||
UpdateIMIntegrationRequest.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
@console_ns.doc(params=query_params_from_model(DeleteIMIntegrationQuery))
|
||||
@console_ns.response(204, "IM integration deleted successfully")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str):
|
||||
query_params_from_request(DeleteIMIntegrationQuery)
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/im-integration/test")
|
||||
class WorkspaceIMIntegrationTestApi(Resource):
|
||||
@console_ns.expect(console_ns.models[TestIMIntegrationRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[TestIMIntegrationResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
TestIMIntegrationRequest.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/im-sync-runs")
|
||||
class WorkspaceIMSyncRunsApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[CreateIMSyncRunResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/im-sync-runs/latest")
|
||||
class WorkspaceLatestIMSyncRunApi(Resource):
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Return the latest IM sync run summary. The UI uses finished_at as the explicit sync time; "
|
||||
"the response does not include started_by."
|
||||
)
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[GetLatestIMSyncRunResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/im-sync-runs/latest/results")
|
||||
class WorkspaceLatestIMSyncRunResultsApi(Resource):
|
||||
@console_ns.doc(
|
||||
params=query_params_from_model(ListLatestIMSyncRunResultsQuery),
|
||||
description=(
|
||||
"Return one required result bucket from the latest IM sync run using page and limit pagination. "
|
||||
"There is no all filter; the response contains page, limit, and total metadata without a run summary."
|
||||
),
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[ListLatestIMSyncRunResultsResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
ListLatestIMSyncRunResultsQuery.model_validate(request.args.to_dict(flat=True))
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/im-identities")
|
||||
class WorkspaceIMIdentitiesApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(ListIMIdentitiesQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[ListIMIdentitiesResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
ListIMIdentitiesQuery.model_validate(request.args.to_dict(flat=True))
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/contacts/<uuid:contact_id>/im-override")
|
||||
class WorkspaceContactIMOverrideApi(Resource):
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Set or reset the IM override for a contact. "
|
||||
"This endpoint is used to override the IM identity for a contact in the workspace."
|
||||
),
|
||||
)
|
||||
@console_ns.expect(console_ns.models[SetContactIMOverrideRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[SetContactIMOverrideResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str, contact_id: str):
|
||||
# This API only works in EE.
|
||||
SetContactIMOverrideRequest.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Reset the IM override for a contact. "
|
||||
"This endpoint is used to clear the IM identity override for a contact in the workspace."
|
||||
),
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[ResetContactIMOverrideResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str, contact_id: str):
|
||||
# This API only works in EE.
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/contacts/<uuid:contact_id>/im-bindings")
|
||||
class WorkspaceContactIMBindingsApi(Resource):
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Set an IM binding for a contact. Used for binding an IM identity to a contact. "
|
||||
"This endpoint is not used for creating workspace IM override. "
|
||||
"For that purpose, use WorkspaceContactIMOverrideApi.put instead."
|
||||
),
|
||||
)
|
||||
@console_ns.expect(console_ns.models[CreateIMBindingRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[CreateIMBindingResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str, contact_id: str):
|
||||
CreateIMBindingRequest.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
@console_ns.response(200, "Success", console_ns.models[DeleteIMBindingResponse.__name__])
|
||||
@console_ns.doc(
|
||||
params=query_params_from_model(DeleteIMBindingQuery),
|
||||
description=(
|
||||
"Delete an IM binding for a contact. Used for removing contact IM binding information. "
|
||||
"This endpoint is not used for resetting workspace IM override. For that purpose, use "
|
||||
"WorkspaceContactIMOverrideApi.delete instead."
|
||||
),
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str, contact_id: str):
|
||||
query_params_from_request(DeleteIMBindingQuery)
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/contacts/batch")
|
||||
class BatchGetContactsAPI(Resource):
|
||||
@console_ns.doc(
|
||||
params=query_params_from_model(BatchGetContactsQuery),
|
||||
description=(
|
||||
"Admin-only batch lookup for Contact management clients. "
|
||||
"Workflow editors must use the editor-safe contact-options/batch projection."
|
||||
),
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[BatchGetContactsResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
query_params_from_request(BatchGetContactsQuery, list_fields=("contact_ids",))
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/contact-options/batch")
|
||||
class BatchGetContactOptionsAPI(Resource):
|
||||
"""Resolve persisted Contact IDs through the same editor-safe selection projection."""
|
||||
|
||||
@console_ns.doc(
|
||||
params=query_params_from_model(BatchGetContactOptionsQuery),
|
||||
description=(
|
||||
"Resolve Contact IDs persisted in workflow recipient configuration. "
|
||||
"Contacts that resolve as ABSENT or are otherwise unavailable in the current workspace are omitted."
|
||||
),
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[BatchGetContactOptionsResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
query_params_from_request(BatchGetContactOptionsQuery, list_fields=("contact_ids",))
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/node-data-migration")
|
||||
class NodeDataMigrationAPI(Resource):
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Migrate node data from HITLv1 to HITLv2. "
|
||||
'A missing legacy version defaults to "1"; any other explicit version is rejected. '
|
||||
"This endpoint only returns the migrated Human Input v2 node data to the client. "
|
||||
"It does not update the workflow DSL."
|
||||
),
|
||||
)
|
||||
@console_ns.expect(console_ns.models[NodeDataMigrationPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[NodeDataMigrationResponse.__name__])
|
||||
@console_ns.response(400, "Migration failed", console_ns.models[NodeDataMigrationFailureResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
NodeDataMigrationPayload.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/email-provider")
|
||||
class HumanInputEmailProviderAPI(Resource):
|
||||
@console_ns.doc(description="Retrieve the current email provider settings for human input")
|
||||
@console_ns.response(200, "Success", console_ns.models[GetEmailProviderResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
@console_ns.doc(description="update the current email provider settings for human input")
|
||||
@console_ns.expect(console_ns.models[SetEmailProviderRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[SetEmailProviderResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str):
|
||||
SetEmailProviderRequest.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
@@ -23,7 +23,6 @@ from .knowledge import retrieval as _knowledge_retrieval
|
||||
from .plugin import agent_config as _agent_config
|
||||
from .plugin import agent_drive as _agent_drive
|
||||
from .plugin import plugin as _plugin
|
||||
from .workspace import plugin_model_providers as _plugin_model_providers
|
||||
from .workspace import workspace as _workspace
|
||||
|
||||
api.add_namespace(inner_api_ns)
|
||||
@@ -36,7 +35,6 @@ __all__ = [
|
||||
"_knowledge_retrieval",
|
||||
"_mail",
|
||||
"_plugin",
|
||||
"_plugin_model_providers",
|
||||
"_runtime_credentials",
|
||||
"_workspace",
|
||||
"api",
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from controllers.common.schema import register_schema_model
|
||||
from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import enterprise_inner_api_only
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
|
||||
class InvalidatePluginModelProvidersCachePayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
tenant_ids: list[str] = Field(default_factory=list, description="Workspace ids whose cache should be invalidated")
|
||||
|
||||
|
||||
register_schema_model(inner_api_ns, InvalidatePluginModelProvidersCachePayload)
|
||||
|
||||
|
||||
@inner_api_ns.route("/enterprise/workspace/plugin-model-providers/invalidate")
|
||||
class EnterprisePluginModelProvidersCacheInvalidate(Resource):
|
||||
@setup_required
|
||||
@enterprise_inner_api_only
|
||||
@inner_api_ns.doc(
|
||||
"enterprise_invalidate_plugin_model_providers_cache",
|
||||
responses={
|
||||
200: "Cache invalidated",
|
||||
400: "Invalid request",
|
||||
401: "Unauthorized - invalid API key",
|
||||
},
|
||||
)
|
||||
@inner_api_ns.expect(inner_api_ns.models[InvalidatePluginModelProvidersCachePayload.__name__])
|
||||
def post(self):
|
||||
args = InvalidatePluginModelProvidersCachePayload.model_validate(inner_api_ns.payload or {})
|
||||
|
||||
for tenant_id in args.tenant_ids:
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
|
||||
return {"result": "success"}, 200
|
||||
@@ -8,18 +8,14 @@ paused human input forms in workflow/chatflow runs.
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
from flask import Response, abort, request
|
||||
from flask import Response
|
||||
from flask_restx import Resource
|
||||
from pydantic import ConfigDict, Field
|
||||
from werkzeug.exceptions import BadRequest, NotFound
|
||||
|
||||
from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values
|
||||
from controllers.common.human_input_v2_contracts import FormDefinitionResponse as HumanInputV2FormDefinitionResponse
|
||||
from controllers.common.human_input_v2_contracts import FormSubmitResponse as HumanInputV2FormSubmitResponse
|
||||
from controllers.common.human_input_v2_contracts import HumanInputV2ServiceFormSubmitRequest, ServiceFormQuery
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.service_api import service_api_ns
|
||||
from controllers.service_api.schema import expect_with_user
|
||||
@@ -47,19 +43,8 @@ class HumanInputFormSubmitResponse(ResponseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
register_schema_models(
|
||||
service_api_ns,
|
||||
HumanInputFormSubmitPayload,
|
||||
HumanInputV2ServiceFormSubmitRequest,
|
||||
ServiceFormQuery,
|
||||
)
|
||||
register_response_schema_models(
|
||||
service_api_ns,
|
||||
HumanInputFormDefinitionResponse,
|
||||
HumanInputFormSubmitResponse,
|
||||
HumanInputV2FormDefinitionResponse,
|
||||
HumanInputV2FormSubmitResponse,
|
||||
)
|
||||
register_schema_models(service_api_ns, HumanInputFormSubmitPayload)
|
||||
register_response_schema_models(service_api_ns, HumanInputFormDefinitionResponse, HumanInputFormSubmitResponse)
|
||||
|
||||
|
||||
def _jsonify_form_definition(form: Form, *, inputs: Sequence[FormInputConfig] = ()) -> Response:
|
||||
@@ -203,33 +188,3 @@ class WorkflowHumanInputFormApi(Resource):
|
||||
raise NotFound("Form not found")
|
||||
|
||||
return {}, 200
|
||||
|
||||
|
||||
@service_api_ns.route("/form/human-input/<string:form_token>")
|
||||
class WorkflowHumanInputV2FormApi(Resource):
|
||||
"""Trusted Service API stub for Human Input v2 forms.
|
||||
|
||||
This route does not alias the v1 handler. The implementation must resolve a
|
||||
v2 form and reject v1 form tokens before reading or submitting it.
|
||||
"""
|
||||
|
||||
@service_api_ns.response(
|
||||
200,
|
||||
"Form retrieved successfully",
|
||||
service_api_ns.models[HumanInputV2FormDefinitionResponse.__name__],
|
||||
)
|
||||
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY, required=True))
|
||||
def get(self, app_model: App, end_user: EndUser, form_token: str):
|
||||
ServiceFormQuery.model_validate(request.args.to_dict(flat=True))
|
||||
abort(HTTPStatus.NOT_IMPLEMENTED, "Human Input v2 Service API stub endpoint is not implemented yet.")
|
||||
|
||||
@service_api_ns.expect(service_api_ns.models[HumanInputV2ServiceFormSubmitRequest.__name__])
|
||||
@service_api_ns.response(
|
||||
200,
|
||||
"Form submitted successfully",
|
||||
service_api_ns.models[HumanInputV2FormSubmitResponse.__name__],
|
||||
)
|
||||
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True))
|
||||
def post(self, app_model: App, end_user: EndUser, form_token: str):
|
||||
HumanInputV2ServiceFormSubmitRequest.model_validate(service_api_ns.payload or {})
|
||||
abort(HTTPStatus.NOT_IMPLEMENTED, "Human Input v2 Service API stub endpoint is not implemented yet.")
|
||||
|
||||
@@ -25,7 +25,6 @@ from . import (
|
||||
forgot_password,
|
||||
human_input_file_upload,
|
||||
human_input_form,
|
||||
human_input_form_access_request,
|
||||
login,
|
||||
message,
|
||||
passport,
|
||||
@@ -50,7 +49,6 @@ __all__ = [
|
||||
"forgot_password",
|
||||
"human_input_file_upload",
|
||||
"human_input_form",
|
||||
"human_input_form_access_request",
|
||||
"login",
|
||||
"message",
|
||||
"passport",
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Public web Human Input v2 form stubs.
|
||||
|
||||
The hyphenated v2 routes are intentionally separate from the legacy underscored
|
||||
Human Input form routes. Each runtime path must reject tokens owned by the other
|
||||
version when the service implementation is added.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
|
||||
from flask import abort
|
||||
from flask_restx import Resource
|
||||
|
||||
from controllers.common.human_input_v2_contracts import (
|
||||
FormAccessRequestResponse,
|
||||
FormDefinitionResponse,
|
||||
FormSubmitResponse,
|
||||
FormUploadTokenResponse,
|
||||
HumanInputV2FormSubmitRequest,
|
||||
)
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.web import web_ns
|
||||
|
||||
register_schema_models(web_ns, HumanInputV2FormSubmitRequest)
|
||||
register_response_schema_models(
|
||||
web_ns,
|
||||
FormAccessRequestResponse,
|
||||
FormDefinitionResponse,
|
||||
FormSubmitResponse,
|
||||
FormUploadTokenResponse,
|
||||
)
|
||||
|
||||
|
||||
def _raise_stub_not_implemented() -> None:
|
||||
abort(HTTPStatus.NOT_IMPLEMENTED, "Human Input v2 form stub endpoint is not implemented yet.")
|
||||
|
||||
|
||||
@web_ns.route("/form/human-input/<string:form_token>")
|
||||
class HumanInputV2FormApi(Resource):
|
||||
"""Read or submit a Human Input v2 form without sharing v1 submission logic."""
|
||||
|
||||
@web_ns.response(200, "Success", web_ns.models[FormDefinitionResponse.__name__])
|
||||
def get(self, form_token: str):
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
@web_ns.expect(web_ns.models[HumanInputV2FormSubmitRequest.__name__])
|
||||
@web_ns.response(200, "Success", web_ns.models[FormSubmitResponse.__name__])
|
||||
def post(self, form_token: str):
|
||||
HumanInputV2FormSubmitRequest.model_validate(web_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@web_ns.route("/form/human-input/<string:form_token>/upload-token")
|
||||
class HumanInputV2FormUploadTokenApi(Resource):
|
||||
"""Issue an upload token for an active Human Input v2 form."""
|
||||
|
||||
@web_ns.response(200, "Success", web_ns.models[FormUploadTokenResponse.__name__])
|
||||
def post(self, form_token: str):
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@web_ns.route("/form/human-input/<string:form_token>/access-request")
|
||||
class FormAccessRequestApi(Resource):
|
||||
@web_ns.response(200, "Success", web_ns.models[FormAccessRequestResponse.__name__])
|
||||
def post(self, form_token: str):
|
||||
_raise_stub_not_implemented()
|
||||
@@ -16,6 +16,7 @@ from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner
|
||||
from core.app.entities.app_invoke_entities import (
|
||||
AdvancedChatAppGenerateEntity,
|
||||
AppGenerateEntity,
|
||||
DifyRunContext,
|
||||
InvokeFrom,
|
||||
)
|
||||
from core.app.entities.queue_entities import (
|
||||
@@ -31,7 +32,7 @@ from core.moderation.base import ModerationError
|
||||
from core.moderation.input_moderation import InputModeration
|
||||
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
|
||||
from core.workflow.node_factory import get_default_root_node_id
|
||||
from core.workflow.nodes.agent_v2.session_cleanup_layer import build_workflow_agent_session_cleanup_layer
|
||||
from core.workflow.nodes.agent_v2.workspace_retirement_layer import build_workflow_agent_workspace_retirement_layer
|
||||
from core.workflow.system_variables import (
|
||||
build_bootstrap_variables,
|
||||
build_system_variables,
|
||||
@@ -267,7 +268,18 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
|
||||
)
|
||||
|
||||
workflow_entry.graph_engine.layer(persistence_layer)
|
||||
workflow_entry.graph_engine.layer(build_workflow_agent_session_cleanup_layer())
|
||||
workflow_entry.graph_engine.layer(
|
||||
build_workflow_agent_workspace_retirement_layer(
|
||||
dify_run_context=DifyRunContext(
|
||||
tenant_id=self._workflow.tenant_id,
|
||||
app_id=self._workflow.app_id,
|
||||
user_id=self.application_generate_entity.user_id,
|
||||
user_from=user_from,
|
||||
invoke_from=invoke_from,
|
||||
trace_session_id=self.application_generate_entity.extras.get("trace_session_id"),
|
||||
)
|
||||
)
|
||||
)
|
||||
conversation_variable_layer = ConversationVariablePersistenceLayer(
|
||||
ConversationVariableUpdater(session_factory.get_session_maker())
|
||||
)
|
||||
|
||||
@@ -33,15 +33,13 @@ from core.app.apps.agent_app.app_runner import AgentAppRunner
|
||||
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
from core.app.apps.agent_app.generate_response_converter import AgentAppGenerateResponseConverter
|
||||
from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder
|
||||
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore
|
||||
from core.app.apps.agent_app.session_store import AgentAppWorkspaceStore
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
from core.app.apps.message_based_app_generator import MessageBasedAppGenerator
|
||||
from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager
|
||||
from core.app.entities.app_invoke_entities import (
|
||||
AGENT_RUNTIME_EXIT_INTENT_ARG,
|
||||
AgentAppGenerateEntity,
|
||||
AgentRuntimeExitIntent,
|
||||
DifyRunContext,
|
||||
InvokeFrom,
|
||||
UserFrom,
|
||||
@@ -51,19 +49,24 @@ from core.db.session_factory import session_factory
|
||||
from core.ops.ops_trace_manager import TraceQueueManager
|
||||
from core.workflow.file_reference import build_file_reference, is_canonical_file_reference
|
||||
from extensions.ext_database import db
|
||||
from models import Account, App, AppModelConfig, EndUser, Message, MessageAnnotation
|
||||
from models import Account, App, AppModelConfig, Conversation, EndUser, Message, MessageAnnotation
|
||||
from models.agent import (
|
||||
APP_BACKED_AGENT_SOURCES,
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigSnapshot,
|
||||
AgentConfigVersionKind,
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
AgentStatus,
|
||||
AgentWorkingResourceStatus,
|
||||
AgentWorkspaceBinding,
|
||||
AgentWorkspaceOwnerType,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.model import load_annotation_reply_config
|
||||
from services.agent.workspace_service import AgentWorkspaceService, WorkspaceOwnerScope
|
||||
from services.conversation_service import ConversationService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -150,19 +153,6 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
inputs = args["inputs"]
|
||||
prompt_file_mappings = args.get("files") or []
|
||||
|
||||
# Resolve the bound roster Agent + its current Agent Soul snapshot.
|
||||
agent, agent_config_id, agent_config_version_kind, agent_soul = self._resolve_agent(
|
||||
app_model,
|
||||
invoke_from=invoke_from,
|
||||
draft_type=args.get("draft_type"),
|
||||
user=user,
|
||||
session=session,
|
||||
)
|
||||
runtime_session_snapshot_id = self._runtime_session_snapshot_id(
|
||||
invoke_from=invoke_from,
|
||||
snapshot_id=agent_config_id,
|
||||
)
|
||||
|
||||
conversation = None
|
||||
conversation_id = args.get("conversation_id")
|
||||
if conversation_id:
|
||||
@@ -170,6 +160,21 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
app_model=app_model, conversation_id=conversation_id, user=user, session=session
|
||||
)
|
||||
|
||||
# New conversations use the current Agent generation. Existing
|
||||
# conversations use the immutable generation named by their Binding.
|
||||
agent, agent_config_id, agent_config_version_kind, agent_soul = self._resolve_agent(
|
||||
app_model,
|
||||
invoke_from=invoke_from,
|
||||
draft_type=args.get("draft_type"),
|
||||
user=user,
|
||||
session=session,
|
||||
conversation=conversation,
|
||||
)
|
||||
session_scope_config_version_id = self._session_scope_config_version_id(
|
||||
invoke_from=invoke_from,
|
||||
config_version_id=agent_config_id,
|
||||
)
|
||||
|
||||
# Build the EasyUI-shaped config from the Agent Soul so the chat pipeline
|
||||
# can persist usage; the answer itself comes from the agent backend.
|
||||
app_model_config = (
|
||||
@@ -186,8 +191,6 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
model_conf = ModelConfigConverter.convert(app_config)
|
||||
|
||||
trace_manager = TraceQueueManager(app_model.id, user.id if isinstance(user, Account) else user.session_id)
|
||||
agent_runtime_exit_intent = self._resolve_agent_runtime_exit_intent(args)
|
||||
|
||||
application_generate_entity = AgentAppGenerateEntity(
|
||||
task_id=str(uuid.uuid4()),
|
||||
app_config=app_config,
|
||||
@@ -215,8 +218,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
agent_id=agent.id,
|
||||
agent_config_snapshot_id=agent_config_id,
|
||||
agent_config_version_kind=agent_config_version_kind,
|
||||
agent_runtime_session_snapshot_id=runtime_session_snapshot_id,
|
||||
agent_runtime_exit_intent=agent_runtime_exit_intent,
|
||||
agent_session_scope_config_version_id=session_scope_config_version_id,
|
||||
)
|
||||
|
||||
conversation, message = self._init_generate_records(
|
||||
@@ -265,6 +267,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
app_model: App,
|
||||
user: Account | EndUser,
|
||||
conversation_id: str,
|
||||
form_id: str,
|
||||
invoke_from: InvokeFrom,
|
||||
session: Session,
|
||||
) -> None:
|
||||
@@ -279,14 +282,21 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
conversation = ConversationService.get_conversation(
|
||||
app_model=app_model, conversation_id=conversation_id, user=user, session=session
|
||||
)
|
||||
draft_type, draft_id = self._resolve_resume_draft(
|
||||
app_model=app_model,
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
form_id=form_id,
|
||||
session=session,
|
||||
)
|
||||
agent, agent_config_id, agent_config_version_kind, agent_soul = self._resolve_agent(
|
||||
app_model,
|
||||
invoke_from=invoke_from,
|
||||
draft_type=self._resume_draft_type(
|
||||
app_model=app_model, conversation=conversation, user=user, session=session
|
||||
),
|
||||
draft_type=draft_type,
|
||||
draft_id=draft_id,
|
||||
user=user,
|
||||
session=session,
|
||||
conversation=conversation,
|
||||
)
|
||||
|
||||
app_model_config = (
|
||||
@@ -384,30 +394,37 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resume_draft_type(
|
||||
*, app_model: App, conversation: Any, user: Account | EndUser, session: Session
|
||||
) -> str | None:
|
||||
def _resolve_resume_draft(
|
||||
*,
|
||||
app_model: App,
|
||||
conversation: Any,
|
||||
user: Account | EndUser,
|
||||
form_id: str,
|
||||
session: Session,
|
||||
) -> tuple[str | None, str | None]:
|
||||
if conversation.invoke_from != InvokeFrom.DEBUGGER:
|
||||
return None
|
||||
active_session = AgentAppRuntimeSessionStore().load_active_session_for_conversation(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
conversation_id=conversation.id,
|
||||
)
|
||||
snapshot_id = active_session.scope.agent_config_snapshot_id if active_session is not None else None
|
||||
if snapshot_id and isinstance(user, Account):
|
||||
draft = session.scalar(
|
||||
select(AgentConfigDraft).where(
|
||||
AgentConfigDraft.tenant_id == app_model.tenant_id,
|
||||
AgentConfigDraft.id == snapshot_id,
|
||||
)
|
||||
return None, None
|
||||
if not isinstance(user, Account):
|
||||
return AgentConfigDraftType.DRAFT.value, None
|
||||
|
||||
build_draft = session.scalar(
|
||||
select(AgentConfigDraft)
|
||||
.join(
|
||||
AgentWorkspaceBinding,
|
||||
AgentWorkspaceBinding.id == AgentConfigDraft.agent_workspace_binding_id,
|
||||
)
|
||||
if draft is not None:
|
||||
if draft.draft_type == AgentConfigDraftType.DEBUG_BUILD and draft.account_id == user.id:
|
||||
return AgentConfigDraftType.DEBUG_BUILD.value
|
||||
if draft.draft_type == AgentConfigDraftType.DRAFT and draft.account_id is None:
|
||||
return AgentConfigDraftType.DRAFT.value
|
||||
return AgentConfigDraftType.DRAFT.value
|
||||
.where(
|
||||
AgentConfigDraft.tenant_id == app_model.tenant_id,
|
||||
AgentConfigDraft.draft_type == AgentConfigDraftType.DEBUG_BUILD,
|
||||
AgentConfigDraft.account_id == user.id,
|
||||
AgentWorkspaceBinding.tenant_id == app_model.tenant_id,
|
||||
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.ACTIVE,
|
||||
AgentWorkspaceBinding.pending_form_id == form_id,
|
||||
)
|
||||
)
|
||||
if build_draft is not None:
|
||||
return AgentConfigDraftType.DEBUG_BUILD.value, build_draft.id
|
||||
return AgentConfigDraftType.DRAFT.value, None
|
||||
|
||||
def _generate_worker(
|
||||
self,
|
||||
@@ -482,7 +499,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
invoke_from=application_generate_entity.invoke_from,
|
||||
)
|
||||
with session_factory.create_session() as session:
|
||||
_, _, agent_soul = self._resolve_agent_by_id(
|
||||
agent, config_version, agent_soul = self._resolve_agent_by_id(
|
||||
tenant_id=app_config.tenant_id,
|
||||
agent_id=application_generate_entity.agent_id,
|
||||
snapshot_id=application_generate_entity.agent_config_snapshot_id,
|
||||
@@ -496,13 +513,18 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
agent_config_snapshot_id=application_generate_entity.agent_config_snapshot_id,
|
||||
agent_config_version_kind=application_generate_entity.agent_config_version_kind,
|
||||
agent_soul=agent_soul,
|
||||
home_snapshot_id=config_version.home_snapshot_id,
|
||||
conversation_id=conversation.id,
|
||||
query=query,
|
||||
message_id=message.id,
|
||||
model_name=application_generate_entity.model_conf.model,
|
||||
queue_manager=queue_manager,
|
||||
session_scope_snapshot_id=application_generate_entity.agent_runtime_session_snapshot_id,
|
||||
agent_runtime_exit_intent=application_generate_entity.agent_runtime_exit_intent,
|
||||
session_scope_snapshot_id=application_generate_entity.agent_session_scope_config_version_id,
|
||||
build_draft_id=(
|
||||
application_generate_entity.agent_config_snapshot_id
|
||||
if application_generate_entity.agent_config_version_kind == AgentConfigVersionKind.BUILD_DRAFT
|
||||
else None
|
||||
),
|
||||
)
|
||||
except GenerateTaskStoppedError:
|
||||
pass
|
||||
@@ -519,18 +541,6 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
raise AgentAppGeneratorError("query is required")
|
||||
return query.replace("\x00", "")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_agent_runtime_exit_intent(args: Mapping[str, Any]) -> AgentRuntimeExitIntent:
|
||||
"""Resolve API-internal runtime exit policy from controller-owned args.
|
||||
|
||||
Only the private controller-injected "delete" value changes behavior.
|
||||
Normal chat and resume flows default/fallback to "suspend" so public
|
||||
payloads and invalid internal values preserve existing semantics.
|
||||
"""
|
||||
if args.get(AGENT_RUNTIME_EXIT_INTENT_ARG) == "delete":
|
||||
return "delete"
|
||||
return "suspend"
|
||||
|
||||
@staticmethod
|
||||
def _build_runner(dify_context: DifyRunContext) -> AgentAppRunner:
|
||||
credentials_provider, _ = build_dify_model_access(dify_context)
|
||||
@@ -546,7 +556,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
stream_run_timeout_seconds=dify_config.AGENT_BACKEND_RUN_TIMEOUT_SECONDS,
|
||||
),
|
||||
event_adapter=AgentBackendRunEventAdapter(),
|
||||
session_store=AgentAppRuntimeSessionStore(),
|
||||
session_store=AgentAppWorkspaceStore(),
|
||||
text_delta_debounce_seconds=dify_config.AGENT_APP_TEXT_DELTA_DEBOUNCE_SECONDS,
|
||||
)
|
||||
|
||||
@@ -610,8 +620,10 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
*,
|
||||
invoke_from: InvokeFrom,
|
||||
draft_type: Any,
|
||||
draft_id: str | None = None,
|
||||
user: Account | EndUser,
|
||||
session: Session,
|
||||
conversation: Conversation | None = None,
|
||||
) -> tuple[Agent, str, Literal["snapshot", "draft", "build_draft"], AgentSoulConfig]:
|
||||
agent = session.scalar(
|
||||
select(Agent)
|
||||
@@ -643,6 +655,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent=agent,
|
||||
draft_type=draft_type,
|
||||
draft_id=draft_id,
|
||||
account_id=user.id if isinstance(user, Account) else None,
|
||||
session=session,
|
||||
)
|
||||
@@ -655,28 +668,82 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
# Public runtime must keep serving the active snapshot even when unpublished draft edits exist.
|
||||
if not agent.active_config_snapshot_id:
|
||||
raise AgentAppNotPublishedError("Agent has not been published")
|
||||
conversation_binding = self._resolve_conversation_binding(
|
||||
session=session,
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
agent_id=agent.id,
|
||||
conversation=conversation,
|
||||
)
|
||||
snapshot_id = (
|
||||
conversation_binding.agent_config_version_id
|
||||
if conversation_binding is not None
|
||||
else agent.active_config_snapshot_id
|
||||
)
|
||||
_, snapshot, agent_soul = self._resolve_agent_by_id(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent.id,
|
||||
snapshot_id=agent.active_config_snapshot_id,
|
||||
snapshot_id=snapshot_id,
|
||||
session=session,
|
||||
)
|
||||
if conversation_binding is not None:
|
||||
AgentWorkspaceService.validate_binding_generation(
|
||||
conversation_binding,
|
||||
base_home_snapshot_id=snapshot.home_snapshot_id,
|
||||
agent_config_version_id=snapshot.id,
|
||||
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
|
||||
)
|
||||
return agent, snapshot.id, "snapshot", agent_soul
|
||||
|
||||
@staticmethod
|
||||
def _runtime_session_snapshot_id(*, invoke_from: InvokeFrom, snapshot_id: str) -> str | None:
|
||||
"""Return the session scope snapshot id for Agent App runtime state.
|
||||
def _resolve_conversation_binding(
|
||||
*,
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
app_id: str,
|
||||
agent_id: str,
|
||||
conversation: Conversation | None,
|
||||
) -> AgentWorkspaceBinding | None:
|
||||
"""Resolve the exact participant generation owned by an existing conversation."""
|
||||
|
||||
if conversation is None or conversation.agent_workspace_binding_id is None:
|
||||
return None
|
||||
binding = AgentWorkspaceService.get_active_binding(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
binding_id=conversation.agent_workspace_binding_id,
|
||||
expected_owner_scope=WorkspaceOwnerScope(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
|
||||
owner_id=conversation.id,
|
||||
),
|
||||
)
|
||||
if binding is None or binding.agent_id != agent_id:
|
||||
raise AgentAppGeneratorError("Conversation participant Binding is unavailable")
|
||||
return binding
|
||||
|
||||
@staticmethod
|
||||
def _session_scope_config_version_id(*, invoke_from: InvokeFrom, config_version_id: str) -> str | None:
|
||||
"""Return the config version id that scopes Agent App session reuse.
|
||||
|
||||
Console preview/debug chat uses a stable Agent draft row id; build mode
|
||||
uses the current user's build-draft row id. Published/web/API runs use
|
||||
immutable published snapshot ids. This keeps runtime session continuity
|
||||
immutable published snapshot ids. This keeps Workspace Binding continuity
|
||||
inside one editable surface without mixing draft/build/published state.
|
||||
"""
|
||||
return snapshot_id
|
||||
del invoke_from
|
||||
return config_version_id
|
||||
|
||||
@staticmethod
|
||||
def _resolve_debug_draft(
|
||||
*, tenant_id: str, agent: Agent, draft_type: Any, account_id: str | None, session: Session
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent: Agent,
|
||||
draft_type: Any,
|
||||
account_id: str | None,
|
||||
session: Session,
|
||||
draft_id: str | None = None,
|
||||
) -> AgentConfigDraft:
|
||||
effective_draft_type = (
|
||||
AgentConfigDraftType.DEBUG_BUILD
|
||||
@@ -700,6 +767,8 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
AgentConfigDraft.draft_type == AgentConfigDraftType.DEBUG_BUILD,
|
||||
AgentConfigDraft.account_id == account_id,
|
||||
)
|
||||
if draft_id is not None:
|
||||
stmt = stmt.where(AgentConfigDraft.id == draft_id)
|
||||
draft = session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1))
|
||||
if draft is not None:
|
||||
return draft
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
Unlike the legacy ``AgentChatAppRunner`` (which runs an in-process ReAct loop),
|
||||
this runner delegates to the Agent backend, consumes the streamed event flow,
|
||||
republishes the assistant answer through the existing EasyUI chat task
|
||||
pipeline, and then either saves or retires the conversation-owned runtime
|
||||
session depending on the turn's exit policy.
|
||||
pipeline, and saves the latest Agenton snapshot on the persistent Binding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,22 +30,20 @@ from clients.agent_backend import (
|
||||
AgentBackendRunFailedInternalEvent,
|
||||
AgentBackendRunSucceededInternalEvent,
|
||||
AgentBackendStreamInternalEvent,
|
||||
extract_runtime_layer_specs,
|
||||
)
|
||||
from clients.agent_backend.session_cleanup import AgentBackendSessionCleanupPayload
|
||||
from core.app.apps.agent_app.runtime_request_builder import (
|
||||
AgentAppRuntimeBuildContext,
|
||||
AgentAppRuntimeRequest,
|
||||
AgentAppRuntimeRequestBuilder,
|
||||
)
|
||||
from core.app.apps.agent_app.session_store import (
|
||||
AgentAppRuntimeSessionStore,
|
||||
AgentAppSessionScope,
|
||||
AgentAppWorkspaceStore,
|
||||
StoredAgentAppSession,
|
||||
)
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
from core.app.entities.app_invoke_entities import AgentRuntimeExitIntent, DifyRunContext
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext
|
||||
from core.app.entities.queue_entities import (
|
||||
QueueAgentMessageEvent,
|
||||
QueueAgentThoughtEvent,
|
||||
@@ -67,10 +64,10 @@ from graphon.model_runtime.errors.invoke import (
|
||||
InvokeRateLimitError,
|
||||
InvokeServerUnavailableError,
|
||||
)
|
||||
from models.agent import AgentConfigVersionKind
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import MessageAgentThought
|
||||
from tasks.agent_backend_session_cleanup_task import cleanup_conversation_agent_runtime_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -620,7 +617,7 @@ class AgentAppRunner:
|
||||
request_builder: AgentAppRuntimeRequestBuilder,
|
||||
agent_backend_client: AgentBackendRunClient,
|
||||
event_adapter: AgentBackendRunEventAdapter,
|
||||
session_store: AgentAppRuntimeSessionStore,
|
||||
session_store: AgentAppWorkspaceStore,
|
||||
text_delta_debounce_seconds: float,
|
||||
) -> None:
|
||||
self._request_builder = request_builder
|
||||
@@ -637,37 +634,41 @@ class AgentAppRunner:
|
||||
agent_config_snapshot_id: str,
|
||||
agent_config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot",
|
||||
agent_soul: AgentSoulConfig,
|
||||
home_snapshot_id: str,
|
||||
conversation_id: str,
|
||||
query: str,
|
||||
message_id: str,
|
||||
model_name: str,
|
||||
queue_manager: AppQueueManager,
|
||||
session_scope_snapshot_id: str | None | _DefaultSessionScopeSnapshotId = _DEFAULT_SESSION_SCOPE_SNAPSHOT_ID,
|
||||
agent_runtime_exit_intent: AgentRuntimeExitIntent = "suspend",
|
||||
build_draft_id: str | None = None,
|
||||
) -> None:
|
||||
preserve_session = agent_runtime_exit_intent == "suspend"
|
||||
scope = self._build_session_scope(
|
||||
dify_context=dify_context,
|
||||
agent_id=agent_id,
|
||||
agent_config_snapshot_id=agent_config_snapshot_id,
|
||||
home_snapshot_id=home_snapshot_id,
|
||||
conversation_id=conversation_id,
|
||||
session_scope_snapshot_id=session_scope_snapshot_id,
|
||||
agent_config_version_kind=AgentConfigVersionKind(agent_config_version_kind),
|
||||
build_draft_id=build_draft_id,
|
||||
)
|
||||
# ENG-638: if a prior turn paused on ask_human and the form is now answered,
|
||||
# resume by threading the human's reply into this run as deferred_tool_results.
|
||||
stored = self._session_store.load_active_session(scope)
|
||||
stored = self._session_store.load_or_create(scope)
|
||||
runtime = self._build_runtime(
|
||||
dify_context=dify_context,
|
||||
agent_id=agent_id,
|
||||
agent_config_snapshot_id=agent_config_snapshot_id,
|
||||
agent_config_version_kind=agent_config_version_kind,
|
||||
agent_soul=agent_soul,
|
||||
binding_id=stored.binding_id,
|
||||
backend_binding_ref=stored.backend_binding_ref,
|
||||
conversation_id=conversation_id,
|
||||
query=query,
|
||||
idempotency_key=message_id,
|
||||
stored=stored,
|
||||
message_id=message_id,
|
||||
suspend_on_exit=preserve_session,
|
||||
)
|
||||
|
||||
create_response = self._agent_backend_client.create_run(runtime.request)
|
||||
@@ -681,9 +682,6 @@ class AgentAppRunner:
|
||||
)
|
||||
|
||||
if isinstance(terminal, AgentBackendDeferredToolCallInternalEvent):
|
||||
if not preserve_session:
|
||||
self._mark_session_cleaned(scope=scope, backend_run_id=terminal.run_id)
|
||||
raise AgentBackendError("Agent App finalization cannot pause for human input.")
|
||||
# ENG-635: the agent asked a human. End this turn with the question and
|
||||
# a conversation-owned HITL form; a form submission resumes the run.
|
||||
self._pause_for_ask_human(
|
||||
@@ -703,8 +701,8 @@ class AgentAppRunner:
|
||||
if not isinstance(terminal, AgentBackendRunSucceededInternalEvent):
|
||||
if isinstance(terminal, AgentBackendRunFailedInternalEvent):
|
||||
reason = terminal.reason
|
||||
if reason == "sandbox_expired":
|
||||
raise AgentBackendError("The agent session sandbox has expired. Please start a new conversation.")
|
||||
if reason == "binding_lost":
|
||||
raise AgentBackendError("The retained agent working environment is no longer available.")
|
||||
raise _agent_backend_failure_to_exception(terminal)
|
||||
raise AgentBackendError("Agent backend run did not complete successfully.")
|
||||
|
||||
@@ -719,38 +717,18 @@ class AgentAppRunner:
|
||||
message_id,
|
||||
exc_info=True,
|
||||
)
|
||||
if preserve_session:
|
||||
superseded_sessions = self._load_superseded_sessions(scope=scope)
|
||||
self._publish_terminal_answer(
|
||||
queue_manager=queue_manager,
|
||||
model_name=model_name,
|
||||
answer=answer,
|
||||
query=query,
|
||||
usage=_llm_usage_from_agent_backend(terminal.usage),
|
||||
)
|
||||
session_saved = self._save_session(
|
||||
scope=scope,
|
||||
backend_run_id=terminal.run_id,
|
||||
snapshot=terminal.session_snapshot,
|
||||
runtime_layer_specs=extract_runtime_layer_specs(runtime.request.composition),
|
||||
)
|
||||
if session_saved:
|
||||
self._cleanup_superseded_sessions(superseded_sessions)
|
||||
else:
|
||||
# The backend has already accepted a terminal success with
|
||||
# delete-on-exit semantics. Local publish/persistence errors must
|
||||
# not keep the API-side session row active, and cleanup failures
|
||||
# must not replace the original publish/error outcome.
|
||||
try:
|
||||
self._publish_terminal_answer(
|
||||
queue_manager=queue_manager,
|
||||
model_name=model_name,
|
||||
answer=answer,
|
||||
query=query,
|
||||
usage=_llm_usage_from_agent_backend(terminal.usage),
|
||||
)
|
||||
finally:
|
||||
self._mark_session_cleaned(scope=scope, backend_run_id=terminal.run_id)
|
||||
self._publish_terminal_answer(
|
||||
queue_manager=queue_manager,
|
||||
model_name=model_name,
|
||||
answer=answer,
|
||||
query=query,
|
||||
usage=_llm_usage_from_agent_backend(terminal.usage),
|
||||
)
|
||||
self._save_session(
|
||||
scope=scope,
|
||||
binding_id=runtime.binding_id,
|
||||
snapshot=terminal.session_snapshot,
|
||||
)
|
||||
|
||||
def _build_session_scope(
|
||||
self,
|
||||
@@ -758,8 +736,11 @@ class AgentAppRunner:
|
||||
dify_context: DifyRunContext,
|
||||
agent_id: str,
|
||||
agent_config_snapshot_id: str,
|
||||
home_snapshot_id: str,
|
||||
conversation_id: str,
|
||||
session_scope_snapshot_id: str | None | _DefaultSessionScopeSnapshotId,
|
||||
agent_config_version_kind: AgentConfigVersionKind,
|
||||
build_draft_id: str | None = None,
|
||||
) -> AgentAppSessionScope:
|
||||
if isinstance(session_scope_snapshot_id, _DefaultSessionScopeSnapshotId):
|
||||
effective_session_scope_snapshot_id: str | None = agent_config_snapshot_id
|
||||
@@ -770,7 +751,10 @@ class AgentAppRunner:
|
||||
app_id=dify_context.app_id,
|
||||
conversation_id=conversation_id,
|
||||
agent_id=agent_id,
|
||||
agent_config_snapshot_id=effective_session_scope_snapshot_id,
|
||||
agent_config_snapshot_id=effective_session_scope_snapshot_id or agent_config_snapshot_id,
|
||||
home_snapshot_id=home_snapshot_id,
|
||||
agent_config_version_kind=agent_config_version_kind,
|
||||
build_draft_id=build_draft_id,
|
||||
)
|
||||
|
||||
def _build_runtime(
|
||||
@@ -781,14 +765,15 @@ class AgentAppRunner:
|
||||
agent_config_snapshot_id: str,
|
||||
agent_config_version_kind: Literal["snapshot", "draft", "build_draft"],
|
||||
agent_soul: AgentSoulConfig,
|
||||
binding_id: str,
|
||||
backend_binding_ref: str,
|
||||
conversation_id: str,
|
||||
query: str,
|
||||
idempotency_key: str,
|
||||
stored: StoredAgentAppSession | None,
|
||||
stored: StoredAgentAppSession,
|
||||
message_id: str | None,
|
||||
suspend_on_exit: bool,
|
||||
) -> AgentAppRuntimeRequest:
|
||||
session_snapshot = stored.session_snapshot if stored is not None else None
|
||||
session_snapshot = stored.session_snapshot
|
||||
deferred_tool_results = (
|
||||
self._resolve_pending_ask_human(stored=stored, dify_context=dify_context, message_id=message_id)
|
||||
if message_id is not None
|
||||
@@ -804,9 +789,10 @@ class AgentAppRunner:
|
||||
conversation_id=conversation_id,
|
||||
user_query=query,
|
||||
idempotency_key=idempotency_key,
|
||||
binding_id=binding_id,
|
||||
backend_binding_ref=backend_binding_ref,
|
||||
session_snapshot=session_snapshot,
|
||||
deferred_tool_results=deferred_tool_results,
|
||||
suspend_on_exit=suspend_on_exit,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -843,9 +829,8 @@ class AgentAppRunner:
|
||||
# second run with the human's answer (ENG-637/638 columns, conversation owner).
|
||||
self._save_session(
|
||||
scope=scope,
|
||||
backend_run_id=terminal.run_id,
|
||||
binding_id=runtime.binding_id,
|
||||
snapshot=terminal.session_snapshot,
|
||||
runtime_layer_specs=extract_runtime_layer_specs(runtime.request.composition),
|
||||
pending_form_id=created.form_id,
|
||||
pending_tool_call_id=terminal.deferred_tool_call.tool_call_id,
|
||||
)
|
||||
@@ -862,12 +847,12 @@ class AgentAppRunner:
|
||||
def _resolve_pending_ask_human(
|
||||
self,
|
||||
*,
|
||||
stored: StoredAgentAppSession | None,
|
||||
stored: StoredAgentAppSession,
|
||||
dify_context: DifyRunContext,
|
||||
message_id: str,
|
||||
) -> DeferredToolResultsPayload | None:
|
||||
"""Build deferred_tool_results when a pending ask_human form is answered."""
|
||||
if stored is None or stored.pending_form_id is None or stored.pending_tool_call_id is None:
|
||||
if stored.pending_form_id is None or stored.pending_tool_call_id is None:
|
||||
return None
|
||||
outcome = resolve_ask_human_form(
|
||||
form_id=stored.pending_form_id,
|
||||
@@ -1036,18 +1021,16 @@ class AgentAppRunner:
|
||||
self,
|
||||
*,
|
||||
scope: AgentAppSessionScope,
|
||||
backend_run_id: str,
|
||||
binding_id: str,
|
||||
snapshot: Any,
|
||||
runtime_layer_specs: Any,
|
||||
pending_form_id: str | None = None,
|
||||
pending_tool_call_id: str | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
self._session_store.save_active_snapshot(
|
||||
scope=scope,
|
||||
backend_run_id=backend_run_id,
|
||||
binding_id=binding_id,
|
||||
snapshot=snapshot,
|
||||
runtime_layer_specs=runtime_layer_specs,
|
||||
pending_form_id=pending_form_id,
|
||||
pending_tool_call_id=pending_tool_call_id,
|
||||
)
|
||||
@@ -1064,87 +1047,6 @@ class AgentAppRunner:
|
||||
)
|
||||
return False
|
||||
|
||||
def _load_superseded_sessions(self, *, scope: AgentAppSessionScope) -> list[StoredAgentAppSession]:
|
||||
try:
|
||||
stored_sessions = self._session_store.list_active_sessions_for_conversation(
|
||||
tenant_id=scope.tenant_id,
|
||||
app_id=scope.app_id,
|
||||
conversation_id=scope.conversation_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to load existing Agent App conversation sessions before snapshot save: "
|
||||
"tenant_id=%s app_id=%s conversation_id=%s agent_id=%s",
|
||||
scope.tenant_id,
|
||||
scope.app_id,
|
||||
scope.conversation_id,
|
||||
scope.agent_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
|
||||
return [stored for stored in stored_sessions if stored.scope != scope]
|
||||
|
||||
def _cleanup_superseded_sessions(self, stored_sessions: list[StoredAgentAppSession]) -> None:
|
||||
for stored_session in stored_sessions:
|
||||
try:
|
||||
if stored_session.runtime_layer_specs:
|
||||
payload = AgentBackendSessionCleanupPayload(
|
||||
session_snapshot=stored_session.session_snapshot,
|
||||
runtime_layer_specs=stored_session.runtime_layer_specs,
|
||||
idempotency_key=(
|
||||
f"{stored_session.scope.tenant_id}:{stored_session.scope.app_id}:"
|
||||
f"{stored_session.scope.conversation_id}:{stored_session.scope.agent_id}:"
|
||||
f"{stored_session.scope.agent_config_snapshot_id or 'no-config'}:"
|
||||
f"superseded-session-cleanup:{stored_session.backend_run_id or 'no-run'}"
|
||||
),
|
||||
metadata={
|
||||
"tenant_id": stored_session.scope.tenant_id,
|
||||
"app_id": stored_session.scope.app_id,
|
||||
"conversation_id": stored_session.scope.conversation_id,
|
||||
"agent_id": stored_session.scope.agent_id,
|
||||
"agent_config_snapshot_id": stored_session.scope.agent_config_snapshot_id,
|
||||
"previous_agent_backend_run_id": stored_session.backend_run_id,
|
||||
},
|
||||
)
|
||||
cleanup_conversation_agent_runtime_session.delay(payload.model_dump(mode="json"))
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to enqueue Agent backend cleanup for superseded Agent App session: "
|
||||
"tenant_id=%s app_id=%s conversation_id=%s agent_id=%s backend_run_id=%s",
|
||||
stored_session.scope.tenant_id,
|
||||
stored_session.scope.app_id,
|
||||
stored_session.scope.conversation_id,
|
||||
stored_session.scope.agent_id,
|
||||
stored_session.backend_run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _mark_session_cleaned(
|
||||
self,
|
||||
*,
|
||||
scope: AgentAppSessionScope,
|
||||
backend_run_id: str,
|
||||
) -> None:
|
||||
"""Best-effort delete-on-exit cleanup for the API-side session row.
|
||||
|
||||
Once the Agent backend reaches a terminal event, cleanup persistence
|
||||
must not replace the original publish/error outcome for that turn.
|
||||
"""
|
||||
try:
|
||||
self._session_store.mark_cleaned(scope=scope, backend_run_id=backend_run_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to retire Agent App conversation session after delete-on-exit: "
|
||||
"tenant_id=%s app_id=%s conversation_id=%s agent_id=%s backend_run_id=%s",
|
||||
scope.tenant_id,
|
||||
scope.app_id,
|
||||
scope.conversation_id,
|
||||
scope.agent_id,
|
||||
backend_run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _terminal_output_to_answer(output: JsonValue) -> str:
|
||||
"""Normalize the backend's terminal output to assistant text.
|
||||
|
||||
@@ -70,11 +70,12 @@ class AgentAppRuntimeBuildContext:
|
||||
conversation_id: str
|
||||
user_query: str
|
||||
idempotency_key: str
|
||||
binding_id: str
|
||||
backend_binding_ref: str
|
||||
agent_config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot"
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
# ENG-638: set when resuming a chat turn after a submitted ask_human form.
|
||||
deferred_tool_results: DeferredToolResultsPayload | None = None
|
||||
suspend_on_exit: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -82,6 +83,7 @@ class AgentAppRuntimeRequest:
|
||||
request: CreateRunRequest
|
||||
redacted_request: dict[str, Any]
|
||||
metadata: dict[str, Any]
|
||||
binding_id: str
|
||||
|
||||
|
||||
class AgentAppRuntimeRequestBuilder:
|
||||
@@ -160,6 +162,7 @@ class AgentAppRuntimeRequestBuilder:
|
||||
invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value),
|
||||
agent_mode="agent_app",
|
||||
),
|
||||
backend_binding_ref=context.backend_binding_ref,
|
||||
# ENG-616: expand slash-menu mention tokens to canonical names so
|
||||
# no frontend-internal {{#…#}} marker ever reaches the model.
|
||||
agent_soul_prompt=expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
|
||||
@@ -175,13 +178,17 @@ class AgentAppRuntimeRequestBuilder:
|
||||
shell_config=build_shell_layer_config(agent_soul),
|
||||
session_snapshot=context.session_snapshot,
|
||||
deferred_tool_results=context.deferred_tool_results,
|
||||
suspend_on_exit=context.suspend_on_exit,
|
||||
idempotency_key=context.idempotency_key,
|
||||
metadata=metadata,
|
||||
)
|
||||
)
|
||||
redacted = cast(dict[str, Any], redact_for_agent_backend_log(request))
|
||||
return AgentAppRuntimeRequest(request=request, redacted_request=redacted, metadata=metadata)
|
||||
return AgentAppRuntimeRequest(
|
||||
request=request,
|
||||
redacted_request=redacted,
|
||||
metadata=metadata,
|
||||
binding_id=context.binding_id,
|
||||
)
|
||||
|
||||
def _build_tool_layers(
|
||||
self,
|
||||
|
||||
@@ -1,255 +1,176 @@
|
||||
"""Conversation-keyed Agent backend session store for the Agent App type.
|
||||
|
||||
Shares the unified ``agent_runtime_sessions`` table with the workflow Agent
|
||||
Node store, but owns rows with ``owner_type = conversation``: one Agent App
|
||||
conversation maps to one Agent session, so multi-turn chat re-enters the same
|
||||
``session_snapshot``. Cross-conversation memory (PRD Global / Per app) is a
|
||||
phase-2 concern and not modeled here.
|
||||
"""
|
||||
"""Persist and resolve the exact participant owned by an Agent App caller."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from dify_agent.protocol import RuntimeLayerSpec
|
||||
from pydantic import TypeAdapter
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.db.session_factory import session_factory
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.agent import (
|
||||
AgentRuntimeSession,
|
||||
AgentRuntimeSessionOwnerType,
|
||||
AgentRuntimeSessionStatus,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigVersionKind,
|
||||
AgentWorkspaceBinding,
|
||||
AgentWorkspaceOwnerType,
|
||||
)
|
||||
from models.model import App, Conversation
|
||||
from services.agent.workspace_service import (
|
||||
AgentWorkspaceNotFoundError,
|
||||
AgentWorkspaceService,
|
||||
WorkspaceOwnerScope,
|
||||
)
|
||||
|
||||
_RUNTIME_LAYER_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec])
|
||||
|
||||
|
||||
def _serialize_runtime_layer_specs(specs: list[RuntimeLayerSpec]) -> str:
|
||||
return _RUNTIME_LAYER_SPECS_ADAPTER.dump_json(specs).decode()
|
||||
|
||||
|
||||
def _deserialize_runtime_layer_specs(value: str | None) -> list[RuntimeLayerSpec]:
|
||||
if not value:
|
||||
return []
|
||||
return _RUNTIME_LAYER_SPECS_ADAPTER.validate_json(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentAppSessionScope:
|
||||
"""Identity of one Agent App conversation session."""
|
||||
|
||||
tenant_id: str
|
||||
app_id: str
|
||||
conversation_id: str
|
||||
agent_id: str
|
||||
agent_config_snapshot_id: str | None
|
||||
agent_config_snapshot_id: str
|
||||
home_snapshot_id: str
|
||||
agent_config_version_kind: AgentConfigVersionKind = AgentConfigVersionKind.SNAPSHOT
|
||||
build_draft_id: str | None = None
|
||||
|
||||
@property
|
||||
def workspace_owner(self) -> WorkspaceOwnerScope:
|
||||
owner_type = (
|
||||
AgentWorkspaceOwnerType.BUILD_DRAFT if self.build_draft_id else AgentWorkspaceOwnerType.CONVERSATION
|
||||
)
|
||||
return WorkspaceOwnerScope(
|
||||
tenant_id=self.tenant_id,
|
||||
app_id=self.app_id,
|
||||
owner_type=owner_type,
|
||||
owner_id=self.build_draft_id or self.conversation_id,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StoredAgentAppSession:
|
||||
"""Persisted Agent App conversation session with reusable runtime specs."""
|
||||
|
||||
scope: AgentAppSessionScope
|
||||
session_snapshot: CompositorSessionSnapshot
|
||||
backend_run_id: str | None
|
||||
runtime_layer_specs: list[RuntimeLayerSpec] = field(default_factory=list)
|
||||
# ENG-635: set while the conversation turn is paused on a dify.ask_human
|
||||
# deferred call, awaiting a HITL form submission.
|
||||
binding_id: str
|
||||
workspace_id: str
|
||||
backend_binding_ref: str
|
||||
session_snapshot: CompositorSessionSnapshot | None
|
||||
pending_form_id: str | None = None
|
||||
pending_tool_call_id: str | None = None
|
||||
|
||||
|
||||
class AgentAppRuntimeSessionStore:
|
||||
"""Persists Agent backend session snapshots for Agent App conversations."""
|
||||
class AgentAppWorkspaceStore:
|
||||
"""Resolve Agent App sessions through a caller-owned Binding pointer."""
|
||||
|
||||
def load_active_snapshot(self, scope: AgentAppSessionScope) -> CompositorSessionSnapshot | None:
|
||||
stored = self.load_active_session(scope)
|
||||
return stored.session_snapshot if stored is not None else None
|
||||
|
||||
def load_active_session(self, scope: AgentAppSessionScope) -> StoredAgentAppSession | None:
|
||||
def load_or_create(self, scope: AgentAppSessionScope) -> StoredAgentAppSession:
|
||||
with session_factory.create_session() as session:
|
||||
row = session.scalar(self._active_stmt(scope))
|
||||
if row is None:
|
||||
return None
|
||||
return StoredAgentAppSession(
|
||||
scope=scope,
|
||||
session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot),
|
||||
backend_run_id=row.backend_run_id,
|
||||
runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs),
|
||||
pending_form_id=row.pending_form_id,
|
||||
pending_tool_call_id=row.pending_tool_call_id,
|
||||
)
|
||||
|
||||
def load_active_session_for_conversation(
|
||||
self, *, tenant_id: str, app_id: str, conversation_id: str
|
||||
) -> StoredAgentAppSession | None:
|
||||
"""Load the latest ACTIVE session for one conversation-level sandbox lookup.
|
||||
|
||||
Sandbox inspection only knows the product locator
|
||||
``tenant_id + app_id + conversation_id``; it does not know which
|
||||
``agent_id`` or Agent Soul snapshot produced the active shell session.
|
||||
This method therefore resolves the newest ACTIVE conversation-owned row
|
||||
for that conversation and returns both the resumable snapshot and the
|
||||
persisted non-sensitive runtime layer specs needed to build a
|
||||
``SandboxLocator``.
|
||||
"""
|
||||
stmt = (
|
||||
select(AgentRuntimeSession)
|
||||
.where(
|
||||
AgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
AgentRuntimeSession.tenant_id == tenant_id,
|
||||
AgentRuntimeSession.app_id == app_id,
|
||||
AgentRuntimeSession.conversation_id == conversation_id,
|
||||
AgentRuntimeSession.status == AgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
.order_by(AgentRuntimeSession.updated_at.desc())
|
||||
)
|
||||
with session_factory.create_session() as session:
|
||||
row = session.scalar(stmt)
|
||||
if row is None:
|
||||
return None
|
||||
return StoredAgentAppSession(
|
||||
scope=AgentAppSessionScope(
|
||||
tenant_id=row.tenant_id,
|
||||
app_id=row.app_id,
|
||||
conversation_id=row.conversation_id or "",
|
||||
agent_id=row.agent_id,
|
||||
agent_config_snapshot_id=row.agent_config_snapshot_id or "",
|
||||
),
|
||||
session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot),
|
||||
backend_run_id=row.backend_run_id,
|
||||
runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs),
|
||||
)
|
||||
|
||||
def list_active_sessions_for_conversation(
|
||||
self, *, tenant_id: str, app_id: str, conversation_id: str
|
||||
) -> list[StoredAgentAppSession]:
|
||||
"""List all ACTIVE conversation-owned sessions for lifecycle cleanup."""
|
||||
stmt = (
|
||||
select(AgentRuntimeSession)
|
||||
.where(
|
||||
AgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
AgentRuntimeSession.tenant_id == tenant_id,
|
||||
AgentRuntimeSession.app_id == app_id,
|
||||
AgentRuntimeSession.conversation_id == conversation_id,
|
||||
AgentRuntimeSession.status == AgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
.order_by(AgentRuntimeSession.updated_at.desc())
|
||||
)
|
||||
with session_factory.create_session() as session:
|
||||
rows = session.scalars(stmt).all()
|
||||
return [
|
||||
StoredAgentAppSession(
|
||||
scope=AgentAppSessionScope(
|
||||
tenant_id=row.tenant_id,
|
||||
app_id=row.app_id,
|
||||
conversation_id=row.conversation_id or "",
|
||||
agent_id=row.agent_id,
|
||||
agent_config_snapshot_id=row.agent_config_snapshot_id,
|
||||
),
|
||||
session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot),
|
||||
backend_run_id=row.backend_run_id,
|
||||
runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs),
|
||||
pending_form_id=row.pending_form_id,
|
||||
pending_tool_call_id=row.pending_tool_call_id,
|
||||
caller = self._load_caller(session=session, scope=scope)
|
||||
binding_id = caller.agent_workspace_binding_id
|
||||
if binding_id is None:
|
||||
binding = AgentWorkspaceService.create_binding(
|
||||
session=session,
|
||||
scope=scope.workspace_owner,
|
||||
agent_id=scope.agent_id,
|
||||
base_home_snapshot_id=scope.home_snapshot_id,
|
||||
agent_config_version_id=scope.agent_config_snapshot_id,
|
||||
agent_config_version_kind=scope.agent_config_version_kind,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
caller.agent_workspace_binding_id = binding.id
|
||||
session.commit()
|
||||
else:
|
||||
binding = self._get_binding(session=session, scope=scope, binding_id=binding_id)
|
||||
return self._stored(scope, binding)
|
||||
|
||||
@staticmethod
|
||||
def _load_caller(*, session: Session, scope: AgentAppSessionScope) -> Conversation | AgentConfigDraft:
|
||||
if scope.build_draft_id is not None:
|
||||
if scope.agent_config_version_kind != AgentConfigVersionKind.BUILD_DRAFT:
|
||||
raise AgentWorkspaceNotFoundError("Build Draft caller requires build_draft generation")
|
||||
draft = session.scalar(
|
||||
select(AgentConfigDraft).where(
|
||||
AgentConfigDraft.id == scope.build_draft_id,
|
||||
AgentConfigDraft.tenant_id == scope.tenant_id,
|
||||
AgentConfigDraft.agent_id == scope.agent_id,
|
||||
AgentConfigDraft.draft_type == AgentConfigDraftType.DEBUG_BUILD,
|
||||
)
|
||||
)
|
||||
if draft is None:
|
||||
raise AgentWorkspaceNotFoundError("Build Draft caller is unavailable")
|
||||
return draft
|
||||
if scope.agent_config_version_kind == AgentConfigVersionKind.BUILD_DRAFT:
|
||||
raise AgentWorkspaceNotFoundError("Build Draft caller ID is required")
|
||||
conversation = session.scalar(
|
||||
select(Conversation)
|
||||
.join(App, App.id == Conversation.app_id)
|
||||
.where(
|
||||
App.tenant_id == scope.tenant_id,
|
||||
Conversation.id == scope.conversation_id,
|
||||
Conversation.app_id == scope.app_id,
|
||||
Conversation.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
if conversation is None:
|
||||
raise AgentWorkspaceNotFoundError("Conversation caller is unavailable")
|
||||
return conversation
|
||||
|
||||
@staticmethod
|
||||
def _get_binding(
|
||||
*,
|
||||
session: Session,
|
||||
scope: AgentAppSessionScope,
|
||||
binding_id: str,
|
||||
) -> AgentWorkspaceBinding:
|
||||
binding = AgentWorkspaceService.get_active_binding(
|
||||
session=session,
|
||||
tenant_id=scope.tenant_id,
|
||||
binding_id=binding_id,
|
||||
expected_owner_scope=scope.workspace_owner,
|
||||
)
|
||||
if binding is None or binding.agent_id != scope.agent_id:
|
||||
raise AgentWorkspaceNotFoundError("Caller participant Binding is unavailable")
|
||||
AgentWorkspaceService.validate_binding_generation(
|
||||
binding,
|
||||
base_home_snapshot_id=scope.home_snapshot_id,
|
||||
agent_config_version_id=scope.agent_config_snapshot_id,
|
||||
agent_config_version_kind=scope.agent_config_version_kind,
|
||||
)
|
||||
return binding
|
||||
|
||||
def save_active_snapshot(
|
||||
self,
|
||||
*,
|
||||
scope: AgentAppSessionScope,
|
||||
backend_run_id: str,
|
||||
binding_id: str,
|
||||
snapshot: CompositorSessionSnapshot | None,
|
||||
runtime_layer_specs: list[RuntimeLayerSpec],
|
||||
pending_form_id: str | None = None,
|
||||
pending_tool_call_id: str | None = None,
|
||||
) -> None:
|
||||
"""Persist the current conversation snapshot and enforce one ACTIVE row.
|
||||
|
||||
Agent App chat treats one conversation as one resumable runtime shell.
|
||||
Saving the latest snapshot therefore upserts the scoped row back to
|
||||
ACTIVE and retires any other ACTIVE conversation-owned rows for the
|
||||
same ``tenant_id + app_id + conversation_id`` so later lookups see a
|
||||
single active session.
|
||||
"""
|
||||
if snapshot is None:
|
||||
return
|
||||
snapshot_json = snapshot.model_dump_json()
|
||||
runtime_layer_specs_json = _serialize_runtime_layer_specs(runtime_layer_specs)
|
||||
with session_factory.create_session() as session:
|
||||
row = session.scalar(self._scope_stmt(scope))
|
||||
if row is None:
|
||||
row = AgentRuntimeSession(
|
||||
tenant_id=scope.tenant_id,
|
||||
app_id=scope.app_id,
|
||||
owner_type=AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
agent_id=scope.agent_id,
|
||||
agent_config_snapshot_id=scope.agent_config_snapshot_id,
|
||||
conversation_id=scope.conversation_id,
|
||||
backend_run_id=backend_run_id,
|
||||
session_snapshot=snapshot_json,
|
||||
composition_layer_specs=runtime_layer_specs_json,
|
||||
status=AgentRuntimeSessionStatus.ACTIVE,
|
||||
pending_form_id=pending_form_id,
|
||||
pending_tool_call_id=pending_tool_call_id,
|
||||
)
|
||||
session.add(row)
|
||||
else:
|
||||
row.backend_run_id = backend_run_id
|
||||
row.session_snapshot = snapshot_json
|
||||
row.composition_layer_specs = runtime_layer_specs_json
|
||||
row.status = AgentRuntimeSessionStatus.ACTIVE
|
||||
row.cleaned_at = None
|
||||
# Set (or clear, when omitted) the ask_human pause correlation.
|
||||
row.pending_form_id = pending_form_id
|
||||
row.pending_tool_call_id = pending_tool_call_id
|
||||
session.flush()
|
||||
other_rows = session.scalars(
|
||||
select(AgentRuntimeSession).where(
|
||||
AgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
AgentRuntimeSession.tenant_id == scope.tenant_id,
|
||||
AgentRuntimeSession.app_id == scope.app_id,
|
||||
AgentRuntimeSession.conversation_id == scope.conversation_id,
|
||||
AgentRuntimeSession.status == AgentRuntimeSessionStatus.ACTIVE,
|
||||
AgentRuntimeSession.id != row.id,
|
||||
)
|
||||
).all()
|
||||
for other_row in other_rows:
|
||||
other_row.status = AgentRuntimeSessionStatus.CLEANED
|
||||
other_row.cleaned_at = naive_utc_now()
|
||||
session.commit()
|
||||
|
||||
def mark_cleaned(self, *, scope: AgentAppSessionScope, backend_run_id: str | None = None) -> None:
|
||||
with session_factory.create_session() as session:
|
||||
row = session.scalar(self._active_stmt(scope))
|
||||
if row is None:
|
||||
return
|
||||
if backend_run_id is not None:
|
||||
row.backend_run_id = backend_run_id
|
||||
row.status = AgentRuntimeSessionStatus.CLEANED
|
||||
row.cleaned_at = naive_utc_now()
|
||||
session.commit()
|
||||
AgentWorkspaceService.save_binding_session_snapshot(
|
||||
tenant_id=scope.tenant_id,
|
||||
binding_id=binding_id,
|
||||
session_snapshot=snapshot.model_dump_json(),
|
||||
pending_form_id=pending_form_id,
|
||||
pending_tool_call_id=pending_tool_call_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _scope_stmt(scope: AgentAppSessionScope):
|
||||
stmt = select(AgentRuntimeSession).where(
|
||||
AgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
AgentRuntimeSession.tenant_id == scope.tenant_id,
|
||||
AgentRuntimeSession.conversation_id == scope.conversation_id,
|
||||
AgentRuntimeSession.agent_id == scope.agent_id,
|
||||
def _stored(scope: AgentAppSessionScope, binding: AgentWorkspaceBinding) -> StoredAgentAppSession:
|
||||
snapshot = (
|
||||
CompositorSessionSnapshot.model_validate_json(binding.session_snapshot)
|
||||
if binding.session_snapshot
|
||||
else None
|
||||
)
|
||||
return StoredAgentAppSession(
|
||||
scope=scope,
|
||||
binding_id=binding.id,
|
||||
workspace_id=binding.workspace_id,
|
||||
backend_binding_ref=binding.backend_binding_ref,
|
||||
session_snapshot=snapshot,
|
||||
pending_form_id=binding.pending_form_id,
|
||||
pending_tool_call_id=binding.pending_tool_call_id,
|
||||
)
|
||||
if scope.agent_config_snapshot_id is None:
|
||||
return stmt.where(AgentRuntimeSession.agent_config_snapshot_id.is_(None))
|
||||
return stmt.where(AgentRuntimeSession.agent_config_snapshot_id == scope.agent_config_snapshot_id)
|
||||
|
||||
@classmethod
|
||||
def _active_stmt(cls, scope: AgentAppSessionScope):
|
||||
return cls._scope_stmt(scope).where(AgentRuntimeSession.status == AgentRuntimeSessionStatus.ACTIVE)
|
||||
|
||||
|
||||
__all__ = ["AgentAppRuntimeSessionStore", "AgentAppSessionScope", "StoredAgentAppSession"]
|
||||
__all__ = ["AgentAppSessionScope", "AgentAppWorkspaceStore", "StoredAgentAppSession"]
|
||||
|
||||
@@ -10,11 +10,11 @@ from core.app.apps.workflow.command_channels import (
|
||||
CombinedCommandChannel,
|
||||
)
|
||||
from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, WorkflowAppGenerateEntity
|
||||
from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer
|
||||
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
|
||||
from core.workflow.node_factory import get_default_root_node_id
|
||||
from core.workflow.nodes.agent_v2.session_cleanup_layer import build_workflow_agent_session_cleanup_layer
|
||||
from core.workflow.nodes.agent_v2.workspace_retirement_layer import build_workflow_agent_workspace_retirement_layer
|
||||
from core.workflow.snippet_start import get_compatible_start_aliases
|
||||
from core.workflow.system_variables import build_bootstrap_variables, build_system_variables
|
||||
from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool
|
||||
@@ -197,7 +197,18 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
|
||||
)
|
||||
|
||||
workflow_entry.graph_engine.layer(persistence_layer)
|
||||
workflow_entry.graph_engine.layer(build_workflow_agent_session_cleanup_layer())
|
||||
workflow_entry.graph_engine.layer(
|
||||
build_workflow_agent_workspace_retirement_layer(
|
||||
dify_run_context=DifyRunContext(
|
||||
tenant_id=self._workflow.tenant_id,
|
||||
app_id=self._workflow.app_id,
|
||||
user_id=self.application_generate_entity.user_id,
|
||||
user_from=user_from,
|
||||
invoke_from=invoke_from,
|
||||
trace_session_id=self.application_generate_entity.extras.get("trace_session_id"),
|
||||
)
|
||||
)
|
||||
)
|
||||
for layer in self._graph_engine_layers:
|
||||
workflow_entry.graph_engine.layer(layer)
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ from graphon.graph_events import (
|
||||
from graphon.runtime import GraphRuntimeState, VariablePool
|
||||
from graphon.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader, load_into_variable_pool
|
||||
from models.workflow import Workflow
|
||||
from tasks.mail_human_input_delivery_task import dispatch_human_input_form_delivery_task
|
||||
from tasks.mail_human_input_delivery_task import dispatch_human_input_email_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -715,12 +715,12 @@ class WorkflowBasedAppRunner:
|
||||
if not reason.form_id:
|
||||
continue
|
||||
try:
|
||||
dispatch_human_input_form_delivery_task.apply_async(
|
||||
dispatch_human_input_email_task.apply_async(
|
||||
kwargs={"form_id": reason.form_id, "node_title": reason.node_title},
|
||||
queue="mail",
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive logging
|
||||
logger.exception("Failed to enqueue human input form delivery task for form %s", reason.form_id)
|
||||
logger.exception("Failed to enqueue human input email task for form %s", reason.form_id)
|
||||
|
||||
def _publish_event(self, event: AppQueueEvent):
|
||||
self._queue_manager.publish(event, PublishFrom.APPLICATION_MANAGER)
|
||||
|
||||
@@ -15,8 +15,6 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
DIFY_RUN_CONTEXT_KEY = "_dify"
|
||||
AGENT_RUNTIME_EXIT_INTENT_ARG = "_agent_runtime_exit_intent"
|
||||
type AgentRuntimeExitIntent = Literal["suspend", "delete"]
|
||||
|
||||
|
||||
class UserFrom(StrEnum):
|
||||
@@ -227,12 +225,8 @@ class AgentAppGenerateEntity(ChatAppGenerateEntity):
|
||||
backend should read from: immutable snapshot, shared draft, or per-user
|
||||
build draft.
|
||||
|
||||
``agent_runtime_session_snapshot_id`` carries the runtime session scope
|
||||
used to resume or suspend within the same editable config surface.
|
||||
|
||||
``agent_runtime_exit_intent`` is API-internal lifecycle policy for the
|
||||
Agent backend session after this turn finishes. Normal chat/resume turns
|
||||
suspend on exit; build-chat finalization deletes the backend runtime.
|
||||
``agent_session_scope_config_version_id`` identifies the draft or immutable
|
||||
config version whose Workspace Binding should be reused for this session.
|
||||
|
||||
``prompt_file_mappings`` preserves the raw request ``files`` array for the
|
||||
Agent backend prompt. These references are appended to the backend prompt
|
||||
@@ -242,8 +236,7 @@ class AgentAppGenerateEntity(ChatAppGenerateEntity):
|
||||
agent_id: str
|
||||
agent_config_snapshot_id: str
|
||||
agent_config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot"
|
||||
agent_runtime_session_snapshot_id: str | None = None
|
||||
agent_runtime_exit_intent: AgentRuntimeExitIntent = "suspend"
|
||||
agent_session_scope_config_version_id: str | None = None
|
||||
prompt_file_mappings: Sequence[JsonValue] = Field(default_factory=list)
|
||||
|
||||
|
||||
|
||||
@@ -20,11 +20,13 @@ from core.helper.trace_id_helper import ParentTraceContext
|
||||
from core.ops.entities.trace_entity import TraceTaskName
|
||||
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
|
||||
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
|
||||
from core.workflow.node_execution_process_data import preserve_workflow_agent_binding_id
|
||||
from core.workflow.system_variables import SystemVariableKey
|
||||
from core.workflow.variable_prefixes import SYSTEM_VARIABLE_NODE_ID
|
||||
from core.workflow.workflow_run_outputs import project_node_outputs_for_workflow_run
|
||||
from graphon.entities import WorkflowExecution, WorkflowNodeExecution
|
||||
from graphon.enums import (
|
||||
BuiltinNodeTypes,
|
||||
WorkflowExecutionStatus,
|
||||
WorkflowNodeExecutionMetadataKey,
|
||||
WorkflowNodeExecutionStatus,
|
||||
@@ -241,7 +243,10 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
|
||||
)
|
||||
|
||||
self._node_execution_cache[event.id] = domain_execution
|
||||
self._workflow_node_execution_repository.save(domain_execution)
|
||||
if event.node_type == BuiltinNodeTypes.AGENT and event.node_version == "2":
|
||||
self._workflow_node_execution_repository.save_synchronously(domain_execution)
|
||||
else:
|
||||
self._workflow_node_execution_repository.save(domain_execution)
|
||||
|
||||
snapshot = _NodeRuntimeSnapshot(
|
||||
node_id=event.node_id,
|
||||
@@ -361,7 +366,11 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
|
||||
def _append_retry_history(self, execution: WorkflowNodeExecution, event: NodeRunRetryEvent) -> None:
|
||||
"""Append a validated full attempt before repository truncation or offload."""
|
||||
finished_at = naive_utc_now()
|
||||
process_data = dict(execution.process_data or {})
|
||||
process_data = preserve_workflow_agent_binding_id(
|
||||
event.node_run_result.process_data,
|
||||
execution.process_data,
|
||||
)
|
||||
process_data = dict(process_data or {})
|
||||
raw_history = process_data.get(RETRY_HISTORY_PROCESS_DATA_KEY)
|
||||
history = list(raw_history) if isinstance(raw_history, list) else []
|
||||
projected_outputs = project_node_outputs_for_workflow_run(
|
||||
@@ -390,11 +399,12 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
|
||||
next_process_data: Mapping[str, Any] | None,
|
||||
) -> Mapping[str, Any] | None:
|
||||
"""Keep internal retry history while replacing node-specific Process Data."""
|
||||
merged_process_data = preserve_workflow_agent_binding_id(existing_process_data, next_process_data)
|
||||
raw_history = (existing_process_data or {}).get(RETRY_HISTORY_PROCESS_DATA_KEY)
|
||||
if not isinstance(raw_history, list) or not raw_history:
|
||||
return next_process_data
|
||||
return merged_process_data
|
||||
|
||||
merged_process_data = dict(next_process_data or {})
|
||||
merged_process_data = dict(merged_process_data or {})
|
||||
merged_process_data[RETRY_HISTORY_PROCESS_DATA_KEY] = raw_history
|
||||
return merged_process_data
|
||||
|
||||
@@ -440,6 +450,11 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
|
||||
outputs=projected_outputs,
|
||||
metadata=node_result.metadata,
|
||||
)
|
||||
else:
|
||||
domain_execution.process_data = preserve_workflow_agent_binding_id(
|
||||
node_result.process_data,
|
||||
domain_execution.process_data,
|
||||
)
|
||||
|
||||
self._workflow_node_execution_repository.save(domain_execution)
|
||||
self._workflow_node_execution_repository.save_execution_data(domain_execution)
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
"""Human Input v2 recipient planning, Form, and OTP aggregate boundaries.
|
||||
|
||||
The package owns separate Form and OTP aggregate boundaries plus grant,
|
||||
delivery, and upload facts. It exposes domain and persistence ports without
|
||||
importing transport, provider, database-session, or ORM concerns.
|
||||
"""
|
||||
|
||||
from .delivery import (
|
||||
ConsoleEndpointConfiguration,
|
||||
DeliveryAttempt,
|
||||
DeliveryEndpoint,
|
||||
DeliveryEndpointConfiguration,
|
||||
EmailEndpointConfiguration,
|
||||
EmailProviderConfiguration,
|
||||
EndpointAccessCapability,
|
||||
IMEndpointConfiguration,
|
||||
UploadCapability,
|
||||
UploadCapabilityRef,
|
||||
UploadFileAssociation,
|
||||
WebEndpointConfiguration,
|
||||
)
|
||||
from .form import (
|
||||
FormCreation,
|
||||
FormInactiveReason,
|
||||
FormSnapshotIdentifierFactory,
|
||||
FormState,
|
||||
FrozenFormAction,
|
||||
FrozenFormDefinition,
|
||||
HumanInputForm,
|
||||
InactiveFormState,
|
||||
InvalidApproverGrantError,
|
||||
InvalidSelectedActionError,
|
||||
SubmissionTransitionDecision,
|
||||
WaitingFormState,
|
||||
)
|
||||
from .frozen_values import FrozenJSONArray, FrozenJSONObject, JSONPrimitive
|
||||
from .grants import ApproverGrant, ApproverGrantRef, DeliveryEndpointRef, FormRef, OTPChallengeRef
|
||||
from .otp import (
|
||||
Clock,
|
||||
ContactOTPSubject,
|
||||
CurrentEmailOTPIdentity,
|
||||
EmailAddressOTPSubject,
|
||||
EmailOTPProofAuthorizationDecision,
|
||||
EmailOTPSubject,
|
||||
OTPChallenge,
|
||||
OTPChallengePublicPrimitive,
|
||||
OTPChallengeRejectionReason,
|
||||
OTPChallengeRepository,
|
||||
OTPChallengeState,
|
||||
OTPCodeHash,
|
||||
OTPCodeHasher,
|
||||
OTPReplacementDecision,
|
||||
OTPVerificationDecision,
|
||||
VerifiedEmailOTPProof,
|
||||
VerifiedEmailOTPProofPrimitive,
|
||||
authorize_email_otp_proof,
|
||||
)
|
||||
from .ports import FormDefinitionProjection, FormDeliveryProjection, FormRepository
|
||||
from .recipient_resolution import (
|
||||
ApprovalSubject,
|
||||
CanonicalSubjectKey,
|
||||
ConsoleEndpointPlan,
|
||||
ContactApprovalSubject,
|
||||
ContactInitiatorSnapshot,
|
||||
DebugRecipientReplacement,
|
||||
DeliveryCapabilitySnapshot,
|
||||
DeliveryEndpointPlan,
|
||||
EmailAddressApprovalSubject,
|
||||
EmailEndpointPlan,
|
||||
EndUserApprovalSubject,
|
||||
EndUserInitiatorSnapshot,
|
||||
IMEndpointPlan,
|
||||
MatchedRecipientSource,
|
||||
RecipientRejectionReason,
|
||||
RecipientResolutionFailureReason,
|
||||
RecipientResolver,
|
||||
RecipientSourceKind,
|
||||
RejectedRecipient,
|
||||
ResolvedApprovalPlan,
|
||||
ResolvedApprover,
|
||||
SubjectSnapshot,
|
||||
WebEndpointPlan,
|
||||
)
|
||||
from .recipient_specifications import (
|
||||
ContactRecipientSpecification,
|
||||
CurrentInitiatorRecipientSpecification,
|
||||
DynamicEmailRecipientSpecification,
|
||||
DynamicRecipientValue,
|
||||
OneTimeEmailRecipientSpecification,
|
||||
RecipientSpecification,
|
||||
RecipientSpecificationKind,
|
||||
UnsupportedDynamicRecipientValue,
|
||||
WorkflowRecipientSpecificationAdapter,
|
||||
)
|
||||
from .submission_authorization import (
|
||||
AccountSubmissionActor,
|
||||
AuthorizationContext,
|
||||
AuthorizedSubmission,
|
||||
CurrentContactAuthorizationFacts,
|
||||
CurrentEndUserAuthorizationFacts,
|
||||
CurrentIMAuthorizationFacts,
|
||||
EmailAddressSubmissionActor,
|
||||
EndUserSubmissionActor,
|
||||
SubmissionActor,
|
||||
SubmissionAuthorizationDecision,
|
||||
SubmissionAuthorizationRejection,
|
||||
SubmissionAuthorizer,
|
||||
VerifiedAccountSessionProof,
|
||||
VerifiedIMIdentityProof,
|
||||
VerifiedSubmissionProof,
|
||||
VerifiedTrustedEndUserProof,
|
||||
)
|
||||
from .submission_ports import (
|
||||
AuthorizedSubmissionCommit,
|
||||
RetryableSubmissionPersistenceError,
|
||||
SubmissionAttemptScope,
|
||||
SubmissionCommitResult,
|
||||
SubmissionCommitStatus,
|
||||
SubmissionRepository,
|
||||
SubmissionTransaction,
|
||||
)
|
||||
from .submission_records import FormAuthorizationAuditEvent, FormAuthorizationAuditEventType, FormSubmission
|
||||
|
||||
__all__ = [
|
||||
"AccountSubmissionActor",
|
||||
"ApprovalSubject",
|
||||
"ApproverGrant",
|
||||
"ApproverGrantRef",
|
||||
"AuthorizationContext",
|
||||
"AuthorizedSubmission",
|
||||
"AuthorizedSubmissionCommit",
|
||||
"CanonicalSubjectKey",
|
||||
"Clock",
|
||||
"ConsoleEndpointConfiguration",
|
||||
"ConsoleEndpointPlan",
|
||||
"ContactApprovalSubject",
|
||||
"ContactInitiatorSnapshot",
|
||||
"ContactOTPSubject",
|
||||
"ContactRecipientSpecification",
|
||||
"CurrentContactAuthorizationFacts",
|
||||
"CurrentEmailOTPIdentity",
|
||||
"CurrentEndUserAuthorizationFacts",
|
||||
"CurrentIMAuthorizationFacts",
|
||||
"CurrentInitiatorRecipientSpecification",
|
||||
"DebugRecipientReplacement",
|
||||
"DeliveryAttempt",
|
||||
"DeliveryCapabilitySnapshot",
|
||||
"DeliveryEndpoint",
|
||||
"DeliveryEndpointConfiguration",
|
||||
"DeliveryEndpointPlan",
|
||||
"DeliveryEndpointRef",
|
||||
"DynamicEmailRecipientSpecification",
|
||||
"DynamicRecipientValue",
|
||||
"EmailAddressApprovalSubject",
|
||||
"EmailAddressOTPSubject",
|
||||
"EmailAddressSubmissionActor",
|
||||
"EmailEndpointConfiguration",
|
||||
"EmailEndpointPlan",
|
||||
"EmailOTPProofAuthorizationDecision",
|
||||
"EmailOTPSubject",
|
||||
"EmailProviderConfiguration",
|
||||
"EndUserApprovalSubject",
|
||||
"EndUserInitiatorSnapshot",
|
||||
"EndUserSubmissionActor",
|
||||
"EndpointAccessCapability",
|
||||
"FormAuthorizationAuditEvent",
|
||||
"FormAuthorizationAuditEventType",
|
||||
"FormCreation",
|
||||
"FormDefinitionProjection",
|
||||
"FormDeliveryProjection",
|
||||
"FormInactiveReason",
|
||||
"FormRef",
|
||||
"FormRepository",
|
||||
"FormSnapshotIdentifierFactory",
|
||||
"FormState",
|
||||
"FormSubmission",
|
||||
"FrozenFormAction",
|
||||
"FrozenFormDefinition",
|
||||
"FrozenJSONArray",
|
||||
"FrozenJSONObject",
|
||||
"HumanInputForm",
|
||||
"IMEndpointConfiguration",
|
||||
"IMEndpointPlan",
|
||||
"InactiveFormState",
|
||||
"InvalidApproverGrantError",
|
||||
"InvalidSelectedActionError",
|
||||
"JSONPrimitive",
|
||||
"MatchedRecipientSource",
|
||||
"OTPChallenge",
|
||||
"OTPChallengePublicPrimitive",
|
||||
"OTPChallengeRef",
|
||||
"OTPChallengeRejectionReason",
|
||||
"OTPChallengeRepository",
|
||||
"OTPChallengeState",
|
||||
"OTPCodeHash",
|
||||
"OTPCodeHasher",
|
||||
"OTPReplacementDecision",
|
||||
"OTPVerificationDecision",
|
||||
"OneTimeEmailRecipientSpecification",
|
||||
"RecipientRejectionReason",
|
||||
"RecipientResolutionFailureReason",
|
||||
"RecipientResolver",
|
||||
"RecipientSourceKind",
|
||||
"RecipientSpecification",
|
||||
"RecipientSpecificationKind",
|
||||
"RejectedRecipient",
|
||||
"ResolvedApprovalPlan",
|
||||
"ResolvedApprover",
|
||||
"RetryableSubmissionPersistenceError",
|
||||
"SubjectSnapshot",
|
||||
"SubmissionActor",
|
||||
"SubmissionAttemptScope",
|
||||
"SubmissionAuthorizationDecision",
|
||||
"SubmissionAuthorizationRejection",
|
||||
"SubmissionAuthorizer",
|
||||
"SubmissionCommitResult",
|
||||
"SubmissionCommitStatus",
|
||||
"SubmissionRepository",
|
||||
"SubmissionTransaction",
|
||||
"SubmissionTransitionDecision",
|
||||
"UnsupportedDynamicRecipientValue",
|
||||
"UploadCapability",
|
||||
"UploadCapabilityRef",
|
||||
"UploadFileAssociation",
|
||||
"VerifiedAccountSessionProof",
|
||||
"VerifiedEmailOTPProof",
|
||||
"VerifiedEmailOTPProofPrimitive",
|
||||
"VerifiedIMIdentityProof",
|
||||
"VerifiedSubmissionProof",
|
||||
"VerifiedTrustedEndUserProof",
|
||||
"WaitingFormState",
|
||||
"WebEndpointConfiguration",
|
||||
"WebEndpointPlan",
|
||||
"WorkflowRecipientSpecificationAdapter",
|
||||
"authorize_email_otp_proof",
|
||||
]
|
||||
@@ -1,287 +0,0 @@
|
||||
"""Frozen delivery, provider, endpoint-token, and upload facts.
|
||||
|
||||
Endpoints describe where a form can be delivered or interacted with. Their
|
||||
tokens are scoped capabilities only; this module deliberately exposes no actor
|
||||
or verified-proof conversion. Delivery attempts are append-only diagnostics and
|
||||
cannot mutate :class:`HumanInputForm` lifecycle state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
from typing import assert_never
|
||||
|
||||
from core.human_input_v2.entities import (
|
||||
EmailProviderType,
|
||||
HumanInputDeliveryAttemptStatus,
|
||||
HumanInputDeliveryChannel,
|
||||
IMProvider,
|
||||
)
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
AppId,
|
||||
DeliveryAttemptId,
|
||||
DeliveryEndpointId,
|
||||
EmailProviderId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IntegrationId,
|
||||
NormalizedEmail,
|
||||
UploadCapabilityId,
|
||||
UploadFileAssociationId,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
)
|
||||
|
||||
from .frozen_values import FrozenJSONObject
|
||||
from .grants import ApproverGrantRef, DeliveryEndpointRef
|
||||
from .recipient_resolution import (
|
||||
ConsoleEndpointPlan,
|
||||
DeliveryEndpointPlan,
|
||||
EmailEndpointPlan,
|
||||
IMEndpointPlan,
|
||||
WebEndpointPlan,
|
||||
)
|
||||
|
||||
|
||||
def _validate_sha256(value: str, *, label: str) -> None:
|
||||
if len(value) != 64 or any(character not in "0123456789abcdef" for character in value):
|
||||
raise ValueError(f"{label} must be a lower-case SHA-256 digest")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EndpointAccessCapability:
|
||||
"""Hashed endpoint interaction capability that carries no identity proof."""
|
||||
|
||||
endpoint_ref: DeliveryEndpointRef
|
||||
token_hash: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_sha256(self.token_hash, label="endpoint access token hash")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EmailEndpointConfiguration:
|
||||
"""Frozen Email delivery address."""
|
||||
|
||||
email_address: NormalizedEmail
|
||||
|
||||
@property
|
||||
def channel(self) -> HumanInputDeliveryChannel:
|
||||
return HumanInputDeliveryChannel.EMAIL
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMEndpointConfiguration:
|
||||
"""Credential-free IM interaction snapshot owned by one integration."""
|
||||
|
||||
integration_id: IntegrationId
|
||||
provider: IMProvider
|
||||
provider_tenant_id: str
|
||||
identity_id: IMIdentityId
|
||||
binding_id: IMBindingId | None
|
||||
provider_user_id: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.provider_tenant_id or not self.provider_user_id:
|
||||
raise ValueError("IM endpoint provider identities must not be blank")
|
||||
|
||||
@property
|
||||
def channel(self) -> HumanInputDeliveryChannel:
|
||||
return HumanInputDeliveryChannel.IM
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WebEndpointConfiguration:
|
||||
"""Public or trusted-app web interaction surface."""
|
||||
|
||||
@property
|
||||
def channel(self) -> HumanInputDeliveryChannel:
|
||||
return HumanInputDeliveryChannel.WEB
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConsoleEndpointConfiguration:
|
||||
"""Authenticated console interaction surface."""
|
||||
|
||||
@property
|
||||
def channel(self) -> HumanInputDeliveryChannel:
|
||||
return HumanInputDeliveryChannel.CONSOLE
|
||||
|
||||
|
||||
type DeliveryEndpointConfiguration = (
|
||||
EmailEndpointConfiguration | IMEndpointConfiguration | WebEndpointConfiguration | ConsoleEndpointConfiguration
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeliveryEndpoint:
|
||||
"""Historical endpoint snapshot distinct from its approver grant."""
|
||||
|
||||
ref: DeliveryEndpointRef
|
||||
configuration: DeliveryEndpointConfiguration
|
||||
address_hash: str
|
||||
access_capability: EndpointAccessCapability | None
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_sha256(self.address_hash, label="endpoint address hash")
|
||||
if self.access_capability is not None and self.access_capability.endpoint_ref != self.ref:
|
||||
raise ValueError("endpoint access capability owner does not match the endpoint")
|
||||
|
||||
@property
|
||||
def id(self) -> DeliveryEndpointId:
|
||||
return self.ref.endpoint_id
|
||||
|
||||
@property
|
||||
def grant_ref(self) -> ApproverGrantRef:
|
||||
return self.ref.grant_ref
|
||||
|
||||
@property
|
||||
def channel(self) -> HumanInputDeliveryChannel:
|
||||
return self.configuration.channel
|
||||
|
||||
@classmethod
|
||||
def from_plan(
|
||||
cls,
|
||||
*,
|
||||
endpoint_id: DeliveryEndpointId,
|
||||
grant_ref: ApproverGrantRef,
|
||||
endpoint_plan: DeliveryEndpointPlan,
|
||||
access_capability: EndpointAccessCapability | None,
|
||||
now: UtcTimestamp,
|
||||
) -> DeliveryEndpoint:
|
||||
endpoint_ref = grant_ref.endpoint(endpoint_id)
|
||||
configuration: DeliveryEndpointConfiguration
|
||||
canonical_address: str
|
||||
match endpoint_plan:
|
||||
case EmailEndpointPlan(email_address=email_address):
|
||||
configuration = EmailEndpointConfiguration(email_address)
|
||||
canonical_address = f"email:{email_address}"
|
||||
case IMEndpointPlan(
|
||||
integration_id=integration_id,
|
||||
provider=provider,
|
||||
provider_tenant_id=provider_tenant_id,
|
||||
identity_id=identity_id,
|
||||
binding_id=binding_id,
|
||||
provider_user_id=provider_user_id,
|
||||
):
|
||||
configuration = IMEndpointConfiguration(
|
||||
integration_id=integration_id,
|
||||
provider=provider,
|
||||
provider_tenant_id=provider_tenant_id,
|
||||
identity_id=identity_id,
|
||||
binding_id=binding_id,
|
||||
provider_user_id=provider_user_id,
|
||||
)
|
||||
canonical_address = f"im:{integration_id}:{provider.value}:{provider_user_id}"
|
||||
case WebEndpointPlan():
|
||||
configuration = WebEndpointConfiguration()
|
||||
canonical_address = "web"
|
||||
case ConsoleEndpointPlan():
|
||||
configuration = ConsoleEndpointConfiguration()
|
||||
canonical_address = "console"
|
||||
case _:
|
||||
assert_never(endpoint_plan)
|
||||
return cls(
|
||||
ref=endpoint_ref,
|
||||
configuration=configuration,
|
||||
address_hash=sha256(canonical_address.encode()).hexdigest(),
|
||||
access_capability=access_capability,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeliveryAttempt:
|
||||
"""Append-only provider delivery outcome scoped to one endpoint."""
|
||||
|
||||
id: DeliveryAttemptId
|
||||
endpoint_ref: DeliveryEndpointRef
|
||||
attempt_number: int
|
||||
status: HumanInputDeliveryAttemptStatus
|
||||
scheduled_at: UtcTimestamp
|
||||
started_at: UtcTimestamp | None
|
||||
finished_at: UtcTimestamp | None
|
||||
provider_message_id: str | None
|
||||
failure_code: str | None
|
||||
failure_reason: str | None
|
||||
provider_response: FrozenJSONObject | None
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.attempt_number < 1:
|
||||
raise ValueError("delivery attempt number must be positive")
|
||||
if self.status is HumanInputDeliveryAttemptStatus.FAILED:
|
||||
if self.finished_at is None:
|
||||
raise ValueError("failed delivery attempt requires finished_at")
|
||||
has_failure_code = self.failure_code is not None and bool(self.failure_code.strip())
|
||||
has_failure_reason = self.failure_reason is not None and bool(self.failure_reason.strip())
|
||||
if not has_failure_code and not has_failure_reason and self.provider_response is None:
|
||||
raise ValueError("failed delivery attempt requires a failure diagnostic")
|
||||
if self.status is not HumanInputDeliveryAttemptStatus.FAILED and (
|
||||
self.failure_code is not None or self.failure_reason is not None
|
||||
):
|
||||
raise ValueError("only failed delivery attempts may contain failure diagnostics")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EmailProviderConfiguration:
|
||||
"""Workspace provider configuration kept outside the form domain lifecycle."""
|
||||
|
||||
id: EmailProviderId
|
||||
workspace_id: WorkspaceId
|
||||
provider: EmailProviderType
|
||||
sender_email: NormalizedEmail
|
||||
sender_name: str
|
||||
encrypted_credentials: FrozenJSONObject
|
||||
configured_by_account_id: AccountId | None
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UploadCapabilityRef:
|
||||
"""Upload capability reference carrying the complete endpoint owner chain."""
|
||||
|
||||
endpoint_ref: DeliveryEndpointRef
|
||||
capability_id: UploadCapabilityId
|
||||
app_id: AppId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UploadCapability:
|
||||
"""Hashed upload capability scoped to exactly one form endpoint."""
|
||||
|
||||
id: UploadCapabilityId
|
||||
endpoint_ref: DeliveryEndpointRef
|
||||
app_id: AppId
|
||||
token_hash: str
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_sha256(self.token_hash, label="upload token hash")
|
||||
|
||||
@property
|
||||
def ref(self) -> UploadCapabilityRef:
|
||||
return UploadCapabilityRef(self.endpoint_ref, self.id, self.app_id)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UploadFileAssociation:
|
||||
"""Durable file fact whose scope is inherited from its upload capability."""
|
||||
|
||||
id: UploadFileAssociationId
|
||||
capability_ref: UploadCapabilityRef
|
||||
upload_file_id: str
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.upload_file_id.strip():
|
||||
raise ValueError("upload file id must not be blank")
|
||||
@@ -1,261 +0,0 @@
|
||||
"""Rich Human Input v2 form aggregate and plan-to-snapshot creation.
|
||||
|
||||
``HumanInputForm`` directly owns every local lifecycle decision: persisted
|
||||
status, node/global expiry, grant membership, and selected actions. It returns a
|
||||
transition decision only; committing the first successful submission belongs to
|
||||
the later submission transaction boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Protocol
|
||||
|
||||
from core.human_input_v2.entities import HumanInputV2FormKind, HumanInputV2FormStatus
|
||||
from core.human_input_v2.shared import (
|
||||
AppId,
|
||||
ApproverGrantId,
|
||||
DeliveryEndpointId,
|
||||
UtcTimestamp,
|
||||
)
|
||||
|
||||
from .delivery import DeliveryEndpoint
|
||||
from .frozen_values import FrozenJSONObject
|
||||
from .grants import ApproverGrant, FormRef
|
||||
from .recipient_resolution import ResolvedApprovalPlan
|
||||
|
||||
|
||||
class InvalidApproverGrantError(ValueError):
|
||||
"""The selected grant does not belong to this form snapshot."""
|
||||
|
||||
|
||||
class InvalidSelectedActionError(ValueError):
|
||||
"""The selected action is absent from the frozen definition."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FrozenFormAction:
|
||||
"""Immutable action values required for display and transition validation."""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
button_style: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.id or not self.title or not self.button_style:
|
||||
raise ValueError("frozen form action values must not be blank")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FrozenFormDefinition:
|
||||
"""Immutable render and validation definition captured at form creation."""
|
||||
|
||||
form_content: str
|
||||
inputs: tuple[FrozenJSONObject, ...]
|
||||
actions: tuple[FrozenFormAction, ...]
|
||||
default_values: FrozenJSONObject
|
||||
node_title: str | None
|
||||
display_in_ui: bool | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.inputs, tuple) or not isinstance(self.actions, tuple):
|
||||
raise TypeError("frozen form definition collections must be immutable tuples")
|
||||
action_ids = [action.id for action in self.actions]
|
||||
if len(action_ids) != len(set(action_ids)):
|
||||
raise ValueError("frozen form action identifiers must be unique")
|
||||
|
||||
def accepts_action(self, selected_action_id: str) -> bool:
|
||||
return any(action.id == selected_action_id for action in self.actions)
|
||||
|
||||
|
||||
class FormInactiveReason(StrEnum):
|
||||
"""Transport-neutral reason why a form cannot accept a transition."""
|
||||
|
||||
SUBMITTED = "submitted"
|
||||
TIMED_OUT = "timed_out"
|
||||
STATUS_EXPIRED = "status_expired"
|
||||
GLOBALLY_EXPIRED = "globally_expired"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WaitingFormState:
|
||||
"""Stable active-state result for a waiting form."""
|
||||
|
||||
is_waiting: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InactiveFormState:
|
||||
"""Stable inactive-state result independent from HTTP status codes."""
|
||||
|
||||
reason: FormInactiveReason
|
||||
is_waiting: bool = False
|
||||
|
||||
|
||||
type FormState = WaitingFormState | InactiveFormState
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SubmissionTransitionDecision:
|
||||
"""Validated intent that does not claim persistence has committed."""
|
||||
|
||||
form_ref: FormRef
|
||||
grant_id: ApproverGrantId
|
||||
selected_action_id: str
|
||||
decided_at: UtcTimestamp
|
||||
|
||||
|
||||
class FormSnapshotIdentifierFactory(Protocol):
|
||||
"""Provide child identifiers without coupling the domain to persistence."""
|
||||
|
||||
def new_grant_id(self) -> ApproverGrantId: ...
|
||||
|
||||
def new_endpoint_id(self) -> DeliveryEndpointId: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FormCreation:
|
||||
"""Complete form/grant/endpoint snapshot persisted by one transaction."""
|
||||
|
||||
form: HumanInputForm
|
||||
endpoints: tuple[DeliveryEndpoint, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.endpoints, tuple):
|
||||
raise TypeError("form creation endpoints must be an immutable tuple")
|
||||
grant_refs = {grant.ref for grant in self.form.grants}
|
||||
if any(endpoint.grant_ref not in grant_refs for endpoint in self.endpoints):
|
||||
raise ValueError("form creation contains an endpoint outside its grants")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HumanInputForm:
|
||||
"""Form root owning local lifecycle and submission transition invariants."""
|
||||
|
||||
ref: FormRef
|
||||
app_id: AppId
|
||||
definition: FrozenFormDefinition
|
||||
rendered_content: str
|
||||
node_timeout_at: UtcTimestamp
|
||||
global_expires_at: UtcTimestamp
|
||||
kind: HumanInputV2FormKind
|
||||
status: HumanInputV2FormStatus
|
||||
workflow_pause_id: str | None
|
||||
node_execution_id: str | None
|
||||
grants: tuple[ApproverGrant, ...]
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.grants, tuple):
|
||||
raise TypeError("form grants must be an immutable tuple")
|
||||
if self.kind is HumanInputV2FormKind.RUNTIME and (
|
||||
self.workflow_pause_id is None or self.node_execution_id is None
|
||||
):
|
||||
raise ValueError("runtime form requires workflow pause and node execution owners")
|
||||
if any(grant.ref.form_ref != self.ref for grant in self.grants):
|
||||
raise ValueError("form contains a grant from another owner")
|
||||
grant_ids = [grant.id for grant in self.grants]
|
||||
subject_keys = [grant.subject_key for grant in self.grants]
|
||||
if len(grant_ids) != len(set(grant_ids)) or len(subject_keys) != len(set(subject_keys)):
|
||||
raise ValueError("form grants must have unique identifiers and canonical subjects")
|
||||
|
||||
def state_at(self, now: UtcTimestamp) -> FormState:
|
||||
"""Return a stable status/expiry decision without changing persisted state."""
|
||||
|
||||
match self.status:
|
||||
case HumanInputV2FormStatus.SUBMITTED:
|
||||
return InactiveFormState(FormInactiveReason.SUBMITTED)
|
||||
case HumanInputV2FormStatus.TIMEOUT:
|
||||
return InactiveFormState(FormInactiveReason.TIMED_OUT)
|
||||
case HumanInputV2FormStatus.EXPIRED:
|
||||
return InactiveFormState(FormInactiveReason.STATUS_EXPIRED)
|
||||
case HumanInputV2FormStatus.WAITING:
|
||||
if now.value >= self.global_expires_at.value:
|
||||
return InactiveFormState(FormInactiveReason.GLOBALLY_EXPIRED)
|
||||
if now.value >= self.node_timeout_at.value:
|
||||
return InactiveFormState(FormInactiveReason.TIMED_OUT)
|
||||
return WaitingFormState()
|
||||
raise AssertionError(f"unsupported Human Input form status: {self.status}")
|
||||
|
||||
def decide_submission(
|
||||
self,
|
||||
*,
|
||||
grant_id: ApproverGrantId,
|
||||
selected_action_id: str,
|
||||
now: UtcTimestamp,
|
||||
) -> SubmissionTransitionDecision | InactiveFormState:
|
||||
"""Validate one local transition without mutating or persisting the form."""
|
||||
|
||||
state = self.state_at(now)
|
||||
if isinstance(state, InactiveFormState):
|
||||
return state
|
||||
if not any(grant.id == grant_id for grant in self.grants):
|
||||
raise InvalidApproverGrantError(str(grant_id))
|
||||
if not self.definition.accepts_action(selected_action_id):
|
||||
raise InvalidSelectedActionError(selected_action_id)
|
||||
return SubmissionTransitionDecision(
|
||||
form_ref=self.ref,
|
||||
grant_id=grant_id,
|
||||
selected_action_id=selected_action_id,
|
||||
decided_at=now,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create_from_plan(
|
||||
cls,
|
||||
*,
|
||||
ref: FormRef,
|
||||
app_id: AppId,
|
||||
definition: FrozenFormDefinition,
|
||||
rendered_content: str,
|
||||
node_timeout_at: UtcTimestamp,
|
||||
global_expires_at: UtcTimestamp,
|
||||
kind: HumanInputV2FormKind,
|
||||
workflow_pause_id: str | None,
|
||||
node_execution_id: str | None,
|
||||
plan: ResolvedApprovalPlan,
|
||||
identifier_factory: FormSnapshotIdentifierFactory,
|
||||
now: UtcTimestamp,
|
||||
) -> FormCreation:
|
||||
"""Map one deterministic resolved plan into a complete frozen snapshot."""
|
||||
|
||||
if not plan.approvers:
|
||||
raise ValueError("form creation requires resolved approvers")
|
||||
grants: list[ApproverGrant] = []
|
||||
endpoints: list[DeliveryEndpoint] = []
|
||||
for approver in plan.approvers:
|
||||
grant = ApproverGrant.from_resolved_approver(
|
||||
grant_id=identifier_factory.new_grant_id(),
|
||||
form_ref=ref,
|
||||
approver=approver,
|
||||
now=now,
|
||||
)
|
||||
grants.append(grant)
|
||||
for endpoint_plan in approver.endpoints:
|
||||
endpoints.append(
|
||||
DeliveryEndpoint.from_plan(
|
||||
endpoint_id=identifier_factory.new_endpoint_id(),
|
||||
grant_ref=grant.ref,
|
||||
endpoint_plan=endpoint_plan,
|
||||
access_capability=None,
|
||||
now=now,
|
||||
)
|
||||
)
|
||||
form = cls(
|
||||
ref=ref,
|
||||
app_id=app_id,
|
||||
definition=definition,
|
||||
rendered_content=rendered_content,
|
||||
node_timeout_at=node_timeout_at,
|
||||
global_expires_at=global_expires_at,
|
||||
kind=kind,
|
||||
status=HumanInputV2FormStatus.WAITING,
|
||||
workflow_pause_id=workflow_pause_id,
|
||||
node_execution_id=node_execution_id,
|
||||
grants=tuple(grants),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
return FormCreation(form=form, endpoints=tuple(endpoints))
|
||||
@@ -1,100 +0,0 @@
|
||||
"""Recursively immutable JSON values used by frozen form snapshots.
|
||||
|
||||
Domain code never receives mutable dictionaries from persistence or provider
|
||||
boundaries. Explicit conversion methods create fresh primitive containers only
|
||||
when a caller crosses such a boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from math import isfinite
|
||||
|
||||
type JSONScalar = str | int | float | bool | None
|
||||
type JSONPrimitive = JSONScalar | list[JSONPrimitive] | dict[str, JSONPrimitive]
|
||||
type FrozenJSONValue = JSONScalar | FrozenJSONArray | FrozenJSONObject
|
||||
|
||||
|
||||
def _validate_frozen_json(value: object) -> None:
|
||||
if value is None or isinstance(value, (str, bool, int)):
|
||||
return
|
||||
if isinstance(value, float):
|
||||
if not isfinite(value):
|
||||
raise ValueError("JSON numbers must be finite")
|
||||
return
|
||||
if isinstance(value, (FrozenJSONArray, FrozenJSONObject)):
|
||||
return
|
||||
raise TypeError(f"frozen JSON values cannot contain {type(value).__name__}")
|
||||
|
||||
|
||||
def _freeze_json(value: object) -> FrozenJSONValue:
|
||||
if value is None or isinstance(value, (str, bool, int)):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
if not isfinite(value):
|
||||
raise ValueError("JSON numbers must be finite")
|
||||
return value
|
||||
if isinstance(value, Mapping):
|
||||
return FrozenJSONObject.from_mapping(value)
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return FrozenJSONArray(tuple(_freeze_json(item) for item in value))
|
||||
raise TypeError(f"unsupported JSON value type: {type(value).__name__}")
|
||||
|
||||
|
||||
def _thaw_json(value: FrozenJSONValue) -> JSONPrimitive:
|
||||
if isinstance(value, FrozenJSONObject):
|
||||
return value.to_mapping()
|
||||
if isinstance(value, FrozenJSONArray):
|
||||
return value.to_list()
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FrozenJSONArray:
|
||||
"""Immutable JSON array with an explicit primitive conversion boundary."""
|
||||
|
||||
values: tuple[FrozenJSONValue, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.values, tuple):
|
||||
raise TypeError("frozen JSON array values must be an immutable tuple")
|
||||
for value in self.values:
|
||||
_validate_frozen_json(value)
|
||||
|
||||
def to_list(self) -> list[JSONPrimitive]:
|
||||
return [_thaw_json(value) for value in self.values]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FrozenJSONObject:
|
||||
"""Immutable ordered JSON object independent from Pydantic and ORM types."""
|
||||
|
||||
entries: tuple[tuple[str, FrozenJSONValue], ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.entries, tuple):
|
||||
raise TypeError("frozen JSON object entries must be an immutable tuple")
|
||||
seen_keys: set[str] = set()
|
||||
for entry in self.entries:
|
||||
if not isinstance(entry, tuple) or len(entry) != 2:
|
||||
raise TypeError("frozen JSON object entries must be key-value tuples")
|
||||
key, value = entry
|
||||
if not isinstance(key, str):
|
||||
raise TypeError("JSON objects require string keys")
|
||||
if key in seen_keys:
|
||||
raise ValueError(f"duplicate JSON object key: {key}")
|
||||
seen_keys.add(key)
|
||||
_validate_frozen_json(value)
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, values: Mapping[str, object]) -> FrozenJSONObject:
|
||||
entries: list[tuple[str, FrozenJSONValue]] = []
|
||||
for key in sorted(values, key=lambda candidate: str(candidate)):
|
||||
if not isinstance(key, str):
|
||||
raise TypeError("JSON objects require string keys")
|
||||
entries.append((key, _freeze_json(values[key])))
|
||||
return cls(tuple(entries))
|
||||
|
||||
def to_mapping(self) -> dict[str, JSONPrimitive]:
|
||||
return {key: _thaw_json(value) for key, value in self.entries}
|
||||
@@ -1,134 +0,0 @@
|
||||
"""Historical approver grants and form-scoped logical references.
|
||||
|
||||
Grants capture candidate authority at form creation. They are intentionally not
|
||||
current authorization proofs: later submission code must revalidate the current
|
||||
identity behind the frozen subject.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import assert_never
|
||||
|
||||
from core.human_input_v2.shared import (
|
||||
ApproverGrantId,
|
||||
DeliveryEndpointId,
|
||||
FormId,
|
||||
OTPChallengeId,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
)
|
||||
|
||||
from .recipient_resolution import (
|
||||
ApprovalSubject,
|
||||
CanonicalSubjectKey,
|
||||
ContactApprovalSubject,
|
||||
EmailAddressApprovalSubject,
|
||||
EndUserApprovalSubject,
|
||||
MatchedRecipientSource,
|
||||
ResolvedApprover,
|
||||
SubjectSnapshot,
|
||||
)
|
||||
|
||||
|
||||
def _subject_key(subject: ApprovalSubject) -> CanonicalSubjectKey:
|
||||
match subject:
|
||||
case ContactApprovalSubject(contact_id=contact_id):
|
||||
return CanonicalSubjectKey.for_contact(contact_id)
|
||||
case EndUserApprovalSubject(end_user_id=end_user_id):
|
||||
return CanonicalSubjectKey.for_end_user(end_user_id)
|
||||
case EmailAddressApprovalSubject(normalized_email=normalized_email):
|
||||
return CanonicalSubjectKey.for_email(normalized_email)
|
||||
case _:
|
||||
assert_never(subject)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FormRef:
|
||||
"""Workspace-owned root reference; authorization still requires scoped queries."""
|
||||
|
||||
workspace_id: WorkspaceId
|
||||
form_id: FormId
|
||||
|
||||
def grant(self, grant_id: ApproverGrantId) -> ApproverGrantRef:
|
||||
return ApproverGrantRef(self, grant_id)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApproverGrantRef:
|
||||
"""Grant reference carrying its complete form owner chain."""
|
||||
|
||||
form_ref: FormRef
|
||||
grant_id: ApproverGrantId
|
||||
|
||||
def endpoint(self, endpoint_id: DeliveryEndpointId) -> DeliveryEndpointRef:
|
||||
return DeliveryEndpointRef(self, endpoint_id)
|
||||
|
||||
def challenge(self, challenge_id: OTPChallengeId) -> OTPChallengeRef:
|
||||
return OTPChallengeRef(self, challenge_id)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OTPChallengeRef:
|
||||
"""OTP proof-session reference carrying its complete grant owner chain."""
|
||||
|
||||
grant_ref: ApproverGrantRef
|
||||
challenge_id: OTPChallengeId
|
||||
|
||||
@property
|
||||
def form_ref(self) -> FormRef:
|
||||
return self.grant_ref.form_ref
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeliveryEndpointRef:
|
||||
"""Endpoint reference carrying grant, form, and workspace ownership."""
|
||||
|
||||
grant_ref: ApproverGrantRef
|
||||
endpoint_id: DeliveryEndpointId
|
||||
|
||||
@property
|
||||
def form_ref(self) -> FormRef:
|
||||
return self.grant_ref.form_ref
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApproverGrant:
|
||||
"""Frozen candidate approver with historical matched-source facts."""
|
||||
|
||||
ref: ApproverGrantRef
|
||||
subject: ApprovalSubject
|
||||
subject_key: CanonicalSubjectKey
|
||||
matched_sources: tuple[MatchedRecipientSource, ...]
|
||||
subject_snapshot: SubjectSnapshot
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.matched_sources, tuple):
|
||||
raise TypeError("matched sources must be an immutable tuple")
|
||||
if self.subject_key != _subject_key(self.subject):
|
||||
raise ValueError("approver grant subject key does not match its subject")
|
||||
|
||||
@property
|
||||
def id(self) -> ApproverGrantId:
|
||||
return self.ref.grant_id
|
||||
|
||||
@classmethod
|
||||
def from_resolved_approver(
|
||||
cls,
|
||||
*,
|
||||
grant_id: ApproverGrantId,
|
||||
form_ref: FormRef,
|
||||
approver: ResolvedApprover,
|
||||
now: UtcTimestamp,
|
||||
) -> ApproverGrant:
|
||||
return cls(
|
||||
ref=form_ref.grant(grant_id),
|
||||
subject=approver.subject,
|
||||
subject_key=approver.subject_key,
|
||||
matched_sources=approver.matched_sources,
|
||||
subject_snapshot=approver.subject_snapshot,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -1,460 +0,0 @@
|
||||
"""Independent Email OTP proof-session lifecycle and limited proof boundary.
|
||||
|
||||
The aggregate owns only challenge expiry, cooldown, counters, verification, and
|
||||
invalidation. It never reads or mutates :class:`HumanInputForm`; submission code
|
||||
must separately compare a verified proof with coherent current identity facts.
|
||||
Plaintext codes are transient method inputs and are never retained or serialized.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import timedelta
|
||||
from enum import StrEnum
|
||||
from typing import Protocol, TypedDict
|
||||
|
||||
from core.human_input_v2.entities import HumanInputAuthorizationProofType, HumanInputOTPChallengeStatus
|
||||
from core.human_input_v2.shared import ContactId, NormalizedEmail, OTPChallengeId, UtcTimestamp
|
||||
|
||||
from .grants import ApproverGrantRef, OTPChallengeRef
|
||||
|
||||
OTP_EXPIRY = timedelta(minutes=10)
|
||||
OTP_RESEND_COOLDOWN = timedelta(seconds=60)
|
||||
OTP_MAX_SEND_COUNT = 5
|
||||
OTP_MAX_ATTEMPT_COUNT = 5
|
||||
|
||||
|
||||
def _validate_sha256(value: str, *, label: str) -> None:
|
||||
if len(value) != 64 or any(character not in "0123456789abcdef" for character in value):
|
||||
raise ValueError(f"{label} must be a lower-case SHA-256 digest")
|
||||
|
||||
|
||||
class Clock(Protocol):
|
||||
"""Narrow clock port used to make all lifecycle boundaries deterministic."""
|
||||
|
||||
def now(self) -> UtcTimestamp: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OTPCodeHash:
|
||||
"""Opaque encoded code digest plus the verifier algorithm discriminator."""
|
||||
|
||||
encoded_value: str
|
||||
algorithm: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.encoded_value or not self.algorithm.strip():
|
||||
raise ValueError("OTP code hash values must not be blank")
|
||||
|
||||
|
||||
class OTPCodeHasher(Protocol):
|
||||
"""Hash and verify transient plaintext without exposing implementation policy."""
|
||||
|
||||
def hash_code(self, plaintext_code: str) -> OTPCodeHash: ...
|
||||
|
||||
def verify_code(self, plaintext_code: str, code_hash: OTPCodeHash) -> bool: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactOTPSubject:
|
||||
"""Contact incarnation captured when an OTP challenge is issued."""
|
||||
|
||||
contact_id: ContactId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EmailAddressOTPSubject:
|
||||
"""Standalone normalized Email subject captured from a one-time grant."""
|
||||
|
||||
normalized_email: NormalizedEmail
|
||||
|
||||
|
||||
type EmailOTPSubject = ContactOTPSubject | EmailAddressOTPSubject
|
||||
|
||||
|
||||
class OTPChallengeRejectionReason(StrEnum):
|
||||
"""Transport-neutral reason for a rejected OTP or proof operation."""
|
||||
|
||||
EXPIRED = "expired"
|
||||
RESEND_COOLDOWN = "resend_cooldown"
|
||||
SEND_LIMIT_REACHED = "send_limit_reached"
|
||||
ATTEMPT_LIMIT_REACHED = "attempt_limit_reached"
|
||||
ALREADY_VERIFIED = "already_verified"
|
||||
INVALIDATED = "invalidated"
|
||||
INVALID_CODE = "invalid_code"
|
||||
RAW_CODE_NOT_VERIFIED = "raw_code_not_verified"
|
||||
GRANT_MISMATCH = "grant_mismatch"
|
||||
STALE_IDENTITY = "stale_identity"
|
||||
|
||||
|
||||
class OTPChallengePublicPrimitive(TypedDict):
|
||||
"""Secret-free diagnostic form of challenge state."""
|
||||
|
||||
otp_challenge_id: str
|
||||
form_id: str
|
||||
approver_grant_id: str
|
||||
status: str
|
||||
email: str
|
||||
send_count: int
|
||||
attempt_count: int
|
||||
expires_at: str
|
||||
resend_after: str
|
||||
verified_at: str | None
|
||||
invalidated_at: str | None
|
||||
|
||||
|
||||
class VerifiedEmailOTPProofPrimitive(TypedDict):
|
||||
"""Primitive proof shape safe for authorization and audit boundaries."""
|
||||
|
||||
type: str
|
||||
otp_challenge_id: str
|
||||
form_id: str
|
||||
approver_grant_id: str
|
||||
subject_type: str
|
||||
contact_id: str | None
|
||||
verified_email: str
|
||||
verified_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VerifiedEmailOTPProof:
|
||||
"""Immutable Email verification fact that carries no submission authority."""
|
||||
|
||||
challenge_ref: OTPChallengeRef
|
||||
subject: EmailOTPSubject
|
||||
normalized_email: NormalizedEmail
|
||||
verified_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if isinstance(self.subject, EmailAddressOTPSubject) and self.subject.normalized_email != self.normalized_email:
|
||||
raise ValueError("OTP proof subject email must match the verified email")
|
||||
|
||||
def to_primitive(self) -> VerifiedEmailOTPProofPrimitive:
|
||||
contact_id: str | None = None
|
||||
subject_type = "email_address"
|
||||
if isinstance(self.subject, ContactOTPSubject):
|
||||
subject_type = "contact"
|
||||
contact_id = str(self.subject.contact_id)
|
||||
return {
|
||||
"type": HumanInputAuthorizationProofType.EMAIL_OTP.value,
|
||||
"otp_challenge_id": str(self.challenge_ref.challenge_id),
|
||||
"form_id": str(self.challenge_ref.form_ref.form_id),
|
||||
"approver_grant_id": str(self.challenge_ref.grant_ref.grant_id),
|
||||
"subject_type": subject_type,
|
||||
"contact_id": contact_id,
|
||||
"verified_email": str(self.normalized_email),
|
||||
"verified_at": self.verified_at.to_primitive(),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OTPChallengeState:
|
||||
"""Stable current usability result independent from transport status codes."""
|
||||
|
||||
status: HumanInputOTPChallengeStatus
|
||||
rejection: OTPChallengeRejectionReason | None
|
||||
|
||||
@property
|
||||
def is_usable(self) -> bool:
|
||||
return self.rejection is None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OTPReplacementDecision:
|
||||
"""Immutable replacement result; persistence commits both states atomically."""
|
||||
|
||||
previous: OTPChallenge
|
||||
replacement: OTPChallenge | None
|
||||
rejection: OTPChallengeRejectionReason | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OTPVerificationDecision:
|
||||
"""Verification result containing either one limited proof or one rejection."""
|
||||
|
||||
challenge: OTPChallenge
|
||||
proof: VerifiedEmailOTPProof | None
|
||||
rejection: OTPChallengeRejectionReason | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OTPChallenge:
|
||||
"""Grant-scoped proof session whose counters never touch Form lifecycle state.
|
||||
|
||||
Persisted sessions reconstruct only when cooldown and expiry are the exact
|
||||
durations derived from ``created_at``; stored timestamps are not policy input.
|
||||
"""
|
||||
|
||||
ref: OTPChallengeRef
|
||||
subject: EmailOTPSubject
|
||||
normalized_email: NormalizedEmail
|
||||
challenge_token_hash: str = field(repr=False)
|
||||
code_hash: OTPCodeHash = field(repr=False)
|
||||
status: HumanInputOTPChallengeStatus
|
||||
expires_at: UtcTimestamp
|
||||
resend_after: UtcTimestamp
|
||||
send_count: int
|
||||
attempt_count: int
|
||||
verified_at: UtcTimestamp | None
|
||||
invalidated_at: UtcTimestamp | None
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_sha256(self.challenge_token_hash, label="challenge token hash")
|
||||
if isinstance(self.subject, EmailAddressOTPSubject) and self.subject.normalized_email != self.normalized_email:
|
||||
raise ValueError("OTP challenge subject email must match the destination email")
|
||||
if not 1 <= self.send_count <= OTP_MAX_SEND_COUNT:
|
||||
raise ValueError("OTP send count is outside the supported range")
|
||||
if not 0 <= self.attempt_count <= OTP_MAX_ATTEMPT_COUNT:
|
||||
raise ValueError("OTP attempt count is outside the supported range")
|
||||
if self.resend_after.value != self.created_at.value + OTP_RESEND_COOLDOWN:
|
||||
raise ValueError("OTP resend_after must equal created_at plus the resend cooldown")
|
||||
if self.expires_at.value != self.created_at.value + OTP_EXPIRY:
|
||||
raise ValueError("OTP expires_at must equal created_at plus the expiry duration")
|
||||
if self.updated_at.value < self.created_at.value:
|
||||
raise ValueError("OTP updated_at must not precede created_at")
|
||||
if self.status is HumanInputOTPChallengeStatus.VERIFIED:
|
||||
if self.verified_at is None or self.invalidated_at is not None:
|
||||
raise ValueError("verified OTP challenge requires only verified_at")
|
||||
elif self.status is HumanInputOTPChallengeStatus.INVALIDATED:
|
||||
if self.invalidated_at is None or self.verified_at is not None:
|
||||
raise ValueError("invalidated OTP challenge requires only invalidated_at")
|
||||
elif self.verified_at is not None or self.invalidated_at is not None:
|
||||
raise ValueError("pending and expired OTP challenges cannot have terminal timestamps")
|
||||
|
||||
@classmethod
|
||||
def issue(
|
||||
cls,
|
||||
*,
|
||||
challenge_ref: OTPChallengeRef,
|
||||
subject: EmailOTPSubject,
|
||||
normalized_email: NormalizedEmail,
|
||||
challenge_token_hash: str,
|
||||
plaintext_code: str,
|
||||
send_count: int,
|
||||
clock: Clock,
|
||||
code_hasher: OTPCodeHasher,
|
||||
) -> OTPChallenge:
|
||||
"""Hash a transient code and create one pending proof session."""
|
||||
|
||||
now = clock.now()
|
||||
return cls(
|
||||
ref=challenge_ref,
|
||||
subject=subject,
|
||||
normalized_email=normalized_email,
|
||||
challenge_token_hash=challenge_token_hash,
|
||||
code_hash=code_hasher.hash_code(plaintext_code),
|
||||
status=HumanInputOTPChallengeStatus.PENDING,
|
||||
expires_at=UtcTimestamp(now.value + OTP_EXPIRY),
|
||||
resend_after=UtcTimestamp(now.value + OTP_RESEND_COOLDOWN),
|
||||
send_count=send_count,
|
||||
attempt_count=0,
|
||||
verified_at=None,
|
||||
invalidated_at=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
def state_at(self, now: UtcTimestamp) -> OTPChallengeState:
|
||||
match self.status:
|
||||
case HumanInputOTPChallengeStatus.VERIFIED:
|
||||
return OTPChallengeState(self.status, OTPChallengeRejectionReason.ALREADY_VERIFIED)
|
||||
case HumanInputOTPChallengeStatus.INVALIDATED:
|
||||
return OTPChallengeState(self.status, OTPChallengeRejectionReason.INVALIDATED)
|
||||
case HumanInputOTPChallengeStatus.EXPIRED:
|
||||
return OTPChallengeState(self.status, OTPChallengeRejectionReason.EXPIRED)
|
||||
case HumanInputOTPChallengeStatus.PENDING:
|
||||
if now.value >= self.expires_at.value:
|
||||
return OTPChallengeState(HumanInputOTPChallengeStatus.EXPIRED, OTPChallengeRejectionReason.EXPIRED)
|
||||
return OTPChallengeState(self.status, None)
|
||||
raise AssertionError(f"unsupported OTP challenge status: {self.status}")
|
||||
|
||||
def replace(
|
||||
self,
|
||||
*,
|
||||
challenge_ref: OTPChallengeRef,
|
||||
challenge_token_hash: str,
|
||||
plaintext_code: str,
|
||||
clock: Clock,
|
||||
code_hasher: OTPCodeHasher,
|
||||
) -> OTPReplacementDecision:
|
||||
"""Prepare an eligible replacement without persisting either state."""
|
||||
|
||||
now = clock.now()
|
||||
state = self.state_at(now)
|
||||
if (
|
||||
state.rejection is OTPChallengeRejectionReason.EXPIRED
|
||||
and self.status is HumanInputOTPChallengeStatus.PENDING
|
||||
):
|
||||
expired = replace(
|
||||
self,
|
||||
status=HumanInputOTPChallengeStatus.EXPIRED,
|
||||
updated_at=now,
|
||||
)
|
||||
return OTPReplacementDecision(expired, None, OTPChallengeRejectionReason.EXPIRED)
|
||||
if state.rejection is not None:
|
||||
return OTPReplacementDecision(self, None, state.rejection)
|
||||
if self.send_count >= OTP_MAX_SEND_COUNT:
|
||||
return OTPReplacementDecision(self, None, OTPChallengeRejectionReason.SEND_LIMIT_REACHED)
|
||||
if now.value < self.resend_after.value:
|
||||
return OTPReplacementDecision(self, None, OTPChallengeRejectionReason.RESEND_COOLDOWN)
|
||||
replacement_challenge = self.issue(
|
||||
challenge_ref=challenge_ref,
|
||||
subject=self.subject,
|
||||
normalized_email=self.normalized_email,
|
||||
challenge_token_hash=challenge_token_hash,
|
||||
plaintext_code=plaintext_code,
|
||||
send_count=self.send_count + 1,
|
||||
clock=clock,
|
||||
code_hasher=code_hasher,
|
||||
)
|
||||
invalidated = replace(
|
||||
self,
|
||||
status=HumanInputOTPChallengeStatus.INVALIDATED,
|
||||
invalidated_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
return OTPReplacementDecision(invalidated, replacement_challenge, None)
|
||||
|
||||
def verify(
|
||||
self,
|
||||
*,
|
||||
plaintext_code: str,
|
||||
clock: Clock,
|
||||
code_hasher: OTPCodeHasher,
|
||||
) -> OTPVerificationDecision:
|
||||
"""Verify one transient code while preserving exact attempt boundaries."""
|
||||
|
||||
now = clock.now()
|
||||
state = self.state_at(now)
|
||||
if (
|
||||
state.rejection is OTPChallengeRejectionReason.EXPIRED
|
||||
and self.status is HumanInputOTPChallengeStatus.PENDING
|
||||
):
|
||||
expired = replace(
|
||||
self,
|
||||
status=HumanInputOTPChallengeStatus.EXPIRED,
|
||||
updated_at=now,
|
||||
)
|
||||
return OTPVerificationDecision(expired, None, OTPChallengeRejectionReason.EXPIRED)
|
||||
if state.rejection is not None:
|
||||
return OTPVerificationDecision(self, None, state.rejection)
|
||||
if self.attempt_count >= OTP_MAX_ATTEMPT_COUNT:
|
||||
return OTPVerificationDecision(self, None, OTPChallengeRejectionReason.ATTEMPT_LIMIT_REACHED)
|
||||
attempt_count = self.attempt_count + 1
|
||||
if not code_hasher.verify_code(plaintext_code, self.code_hash):
|
||||
attempted = replace(self, attempt_count=attempt_count, updated_at=now)
|
||||
return OTPVerificationDecision(attempted, None, OTPChallengeRejectionReason.INVALID_CODE)
|
||||
verified = replace(
|
||||
self,
|
||||
status=HumanInputOTPChallengeStatus.VERIFIED,
|
||||
attempt_count=attempt_count,
|
||||
verified_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
proof = VerifiedEmailOTPProof(
|
||||
challenge_ref=self.ref,
|
||||
subject=self.subject,
|
||||
normalized_email=self.normalized_email,
|
||||
verified_at=now,
|
||||
)
|
||||
return OTPVerificationDecision(verified, proof, None)
|
||||
|
||||
def invalidate(self, *, clock: Clock) -> OTPChallenge:
|
||||
"""Make a pending proof session unusable without changing its counters."""
|
||||
|
||||
if self.status is not HumanInputOTPChallengeStatus.PENDING:
|
||||
return self
|
||||
now = clock.now()
|
||||
return replace(
|
||||
self,
|
||||
status=HumanInputOTPChallengeStatus.INVALIDATED,
|
||||
invalidated_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
def to_public_primitive(self) -> OTPChallengePublicPrimitive:
|
||||
"""Return state diagnostics while deliberately excluding all hashes."""
|
||||
|
||||
return {
|
||||
"otp_challenge_id": str(self.ref.challenge_id),
|
||||
"form_id": str(self.ref.form_ref.form_id),
|
||||
"approver_grant_id": str(self.ref.grant_ref.grant_id),
|
||||
"status": self.status.value,
|
||||
"email": str(self.normalized_email),
|
||||
"send_count": self.send_count,
|
||||
"attempt_count": self.attempt_count,
|
||||
"expires_at": self.expires_at.to_primitive(),
|
||||
"resend_after": self.resend_after.to_primitive(),
|
||||
"verified_at": self.verified_at.to_primitive() if self.verified_at is not None else None,
|
||||
"invalidated_at": self.invalidated_at.to_primitive() if self.invalidated_at is not None else None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CurrentEmailOTPIdentity:
|
||||
"""Coherent current grant subject and Email facts loaded by submission persistence."""
|
||||
|
||||
grant_ref: ApproverGrantRef
|
||||
subject: EmailOTPSubject | None
|
||||
normalized_email: NormalizedEmail | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EmailOTPProofAuthorizationDecision:
|
||||
"""OTP-specific proof decision consumed by the later Submission authorizer."""
|
||||
|
||||
proof: VerifiedEmailOTPProof | None
|
||||
rejection: OTPChallengeRejectionReason | None
|
||||
|
||||
|
||||
class OTPChallengeRepository(Protocol):
|
||||
"""Grant-scoped atomic persistence operations for OTP proof sessions."""
|
||||
|
||||
def issue_initial(
|
||||
self,
|
||||
grant_ref: ApproverGrantRef,
|
||||
*,
|
||||
challenge_id: OTPChallengeId,
|
||||
audit_event_id: str,
|
||||
challenge_token_hash: str,
|
||||
plaintext_code: str,
|
||||
) -> OTPChallenge: ...
|
||||
|
||||
def replace_current(
|
||||
self,
|
||||
grant_ref: ApproverGrantRef,
|
||||
*,
|
||||
challenge_id: OTPChallengeId,
|
||||
audit_event_id: str,
|
||||
challenge_token_hash: str,
|
||||
plaintext_code: str,
|
||||
) -> OTPReplacementDecision: ...
|
||||
|
||||
def verify(self, challenge_ref: OTPChallengeRef, *, plaintext_code: str) -> OTPVerificationDecision: ...
|
||||
|
||||
def invalidate_current(self, grant_ref: ApproverGrantRef) -> OTPChallenge | None: ...
|
||||
|
||||
def load(self, challenge_ref: OTPChallengeRef) -> OTPChallenge | None: ...
|
||||
|
||||
|
||||
def authorize_email_otp_proof(
|
||||
candidate: object,
|
||||
*,
|
||||
current_identity: CurrentEmailOTPIdentity,
|
||||
) -> EmailOTPProofAuthorizationDecision:
|
||||
"""Reject raw codes and stale identity incarnations without authorizing submission."""
|
||||
|
||||
if not isinstance(candidate, VerifiedEmailOTPProof):
|
||||
return EmailOTPProofAuthorizationDecision(None, OTPChallengeRejectionReason.RAW_CODE_NOT_VERIFIED)
|
||||
if candidate.challenge_ref.grant_ref != current_identity.grant_ref:
|
||||
return EmailOTPProofAuthorizationDecision(None, OTPChallengeRejectionReason.GRANT_MISMATCH)
|
||||
if (
|
||||
current_identity.subject is None
|
||||
or current_identity.normalized_email is None
|
||||
or candidate.subject != current_identity.subject
|
||||
or candidate.normalized_email != current_identity.normalized_email
|
||||
):
|
||||
return EmailOTPProofAuthorizationDecision(None, OTPChallengeRejectionReason.STALE_IDENTITY)
|
||||
return EmailOTPProofAuthorizationDecision(candidate, None)
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Operation-oriented persistence ports for the Human Input v2 form boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from core.human_input_v2.entities import HumanInputV2FormStatus
|
||||
from core.human_input_v2.shared import UtcTimestamp, WorkspaceId
|
||||
|
||||
from .delivery import DeliveryAttempt, DeliveryEndpoint, UploadCapability, UploadFileAssociation
|
||||
from .form import FormCreation, FrozenFormDefinition, HumanInputForm
|
||||
from .grants import ApproverGrant, DeliveryEndpointRef, FormRef
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FormDefinitionProjection:
|
||||
"""Read model for rendering a form through one endpoint capability."""
|
||||
|
||||
form_ref: FormRef
|
||||
endpoint_ref: DeliveryEndpointRef
|
||||
definition: FrozenFormDefinition
|
||||
rendered_content: str
|
||||
status: HumanInputV2FormStatus
|
||||
node_timeout_at: UtcTimestamp
|
||||
global_expires_at: UtcTimestamp
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FormDeliveryProjection:
|
||||
"""Read model containing only data needed to deliver one endpoint."""
|
||||
|
||||
form_ref: FormRef
|
||||
grant: ApproverGrant
|
||||
endpoint: DeliveryEndpoint
|
||||
definition: FrozenFormDefinition
|
||||
rendered_content: str
|
||||
|
||||
|
||||
class FormRepository(Protocol):
|
||||
"""Deep adapter contract whose operations own their query and transaction shape."""
|
||||
|
||||
def create_form(self, creation: FormCreation) -> HumanInputForm:
|
||||
"""Persist form, grants, and endpoints atomically."""
|
||||
|
||||
...
|
||||
|
||||
def load_for_lifecycle(self, form_ref: FormRef) -> HumanInputForm | None:
|
||||
"""Load the form and grants required for local transition decisions."""
|
||||
|
||||
...
|
||||
|
||||
def load_delivery_projection(self, endpoint_ref: DeliveryEndpointRef) -> FormDeliveryProjection | None:
|
||||
"""Load exactly one endpoint with its grant and form delivery values."""
|
||||
|
||||
...
|
||||
|
||||
def load_definition_by_endpoint_token(
|
||||
self,
|
||||
*,
|
||||
workspace_id: WorkspaceId,
|
||||
token_hash: str,
|
||||
) -> FormDefinitionProjection | None:
|
||||
"""Resolve a scoped interaction capability without creating authority."""
|
||||
|
||||
...
|
||||
|
||||
def append_delivery_attempt(self, attempt: DeliveryAttempt) -> DeliveryAttempt:
|
||||
"""Append one delivery fact without changing form lifecycle status."""
|
||||
|
||||
...
|
||||
|
||||
def create_upload_capability(self, capability: UploadCapability) -> UploadCapability:
|
||||
"""Persist one endpoint-scoped upload capability."""
|
||||
|
||||
...
|
||||
|
||||
def associate_upload_file(self, association: UploadFileAssociation) -> UploadFileAssociation:
|
||||
"""Associate a file only after validating the full capability owner chain."""
|
||||
|
||||
...
|
||||
@@ -1,775 +0,0 @@
|
||||
"""Canonical approval plan values and single-entry recipient resolution.
|
||||
|
||||
``RecipientResolver.resolve`` is the only public operation that converts saved
|
||||
recipient specifications into approvers. Validation, Contact upgrade, subject
|
||||
deduplication, matched-source aggregation, debug replacement, and endpoint
|
||||
planning stay behind that interface so callers cannot apply them inconsistently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from hashlib import sha256
|
||||
from typing import assert_never
|
||||
|
||||
from core.human_input_v2.contact_directory import (
|
||||
Contact,
|
||||
ContactDirectoryError,
|
||||
ContactDirectoryPolicy,
|
||||
ContactDirectorySnapshot,
|
||||
ContactResolution,
|
||||
)
|
||||
from core.human_input_v2.entities import HumanInputApproverGrantSubjectType, HumanInputDeliveryChannel, IMProvider
|
||||
from core.human_input_v2.im_integration import EffectiveIMBindingSnapshot
|
||||
from core.human_input_v2.shared import (
|
||||
ContactId,
|
||||
EndUserId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IntegrationId,
|
||||
NormalizedEmail,
|
||||
)
|
||||
|
||||
from .recipient_specifications import (
|
||||
ContactRecipientSpecification,
|
||||
CurrentInitiatorRecipientSpecification,
|
||||
DynamicEmailRecipientSpecification,
|
||||
DynamicRecipientValue,
|
||||
OneTimeEmailRecipientSpecification,
|
||||
RecipientSpecification,
|
||||
UnsupportedDynamicRecipientValue,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CanonicalSubjectKey:
|
||||
"""Portable form-scoped deduplication key, not an authorization identity."""
|
||||
|
||||
value: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
namespace, separator, identity = self.value.partition(":")
|
||||
valid_named_identity = namespace in {"contact", "end_user"} and bool(identity)
|
||||
valid_email_digest = (
|
||||
namespace == "email_address"
|
||||
and len(identity) == 64
|
||||
and all(character in "0123456789abcdef" for character in identity)
|
||||
)
|
||||
if not separator or not (valid_named_identity or valid_email_digest):
|
||||
raise ValueError("canonical subject key has an invalid portable format")
|
||||
|
||||
@classmethod
|
||||
def for_contact(cls, contact_id: ContactId) -> CanonicalSubjectKey:
|
||||
return cls(f"contact:{contact_id}")
|
||||
|
||||
@classmethod
|
||||
def for_end_user(cls, end_user_id: EndUserId) -> CanonicalSubjectKey:
|
||||
return cls(f"end_user:{end_user_id}")
|
||||
|
||||
@classmethod
|
||||
def for_email(cls, normalized_email: NormalizedEmail) -> CanonicalSubjectKey:
|
||||
digest = sha256(normalized_email.value.encode()).hexdigest()
|
||||
return cls(f"email_address:{digest}")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactApprovalSubject:
|
||||
"""Approval authority backed by one canonical Contact."""
|
||||
|
||||
contact_id: ContactId
|
||||
|
||||
@property
|
||||
def subject_type(self) -> HumanInputApproverGrantSubjectType:
|
||||
return HumanInputApproverGrantSubjectType.CONTACT
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"type": self.subject_type.value, "contact_id": self.contact_id.to_primitive()}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EndUserApprovalSubject:
|
||||
"""Approval authority backed by one app-scoped EndUser."""
|
||||
|
||||
end_user_id: EndUserId
|
||||
|
||||
@property
|
||||
def subject_type(self) -> HumanInputApproverGrantSubjectType:
|
||||
return HumanInputApproverGrantSubjectType.END_USER
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"type": self.subject_type.value, "end_user_id": self.end_user_id.to_primitive()}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EmailAddressApprovalSubject:
|
||||
"""Task-scoped approval authority backed by one normalized Email address."""
|
||||
|
||||
normalized_email: NormalizedEmail
|
||||
|
||||
@property
|
||||
def subject_type(self) -> HumanInputApproverGrantSubjectType:
|
||||
return HumanInputApproverGrantSubjectType.EMAIL_ADDRESS
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"type": self.subject_type.value, "normalized_email": self.normalized_email.to_primitive()}
|
||||
|
||||
|
||||
type ApprovalSubject = ContactApprovalSubject | EndUserApprovalSubject | EmailAddressApprovalSubject
|
||||
|
||||
|
||||
class RecipientSourceKind(StrEnum):
|
||||
"""Stable source discriminator retained after canonicalization."""
|
||||
|
||||
STATIC_CONTACT = "static_contact"
|
||||
ONE_TIME_EMAIL = "one_time_email"
|
||||
DYNAMIC_EMAIL = "dynamic_email"
|
||||
CURRENT_INITIATOR = "current_initiator"
|
||||
DEBUG_REPLACEMENT = "debug_replacement"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MatchedRecipientSource:
|
||||
"""One ordered configured or request-scoped source of an approver."""
|
||||
|
||||
kind: RecipientSourceKind
|
||||
position: int
|
||||
reference: str | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.position < 0:
|
||||
raise ValueError("recipient source position must not be negative")
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"kind": self.kind.value, "position": self.position, "reference": self.reference}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SubjectSnapshot:
|
||||
"""Display-only identity facts captured by resolution."""
|
||||
|
||||
display_name: str | None
|
||||
email: str | None
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"display_name": self.display_name, "email": self.email}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EmailEndpointPlan:
|
||||
"""Email delivery destination for one canonical approver."""
|
||||
|
||||
email_address: NormalizedEmail
|
||||
|
||||
@property
|
||||
def channel(self) -> HumanInputDeliveryChannel:
|
||||
return HumanInputDeliveryChannel.EMAIL
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"channel": self.channel.value, "email_address": self.email_address.to_primitive()}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMEndpointPlan:
|
||||
"""Credential-free IM delivery destination frozen from an effective binding."""
|
||||
|
||||
integration_id: IntegrationId
|
||||
provider: IMProvider
|
||||
provider_tenant_id: str
|
||||
identity_id: IMIdentityId
|
||||
binding_id: IMBindingId | None
|
||||
provider_user_id: str
|
||||
|
||||
@property
|
||||
def channel(self) -> HumanInputDeliveryChannel:
|
||||
return HumanInputDeliveryChannel.IM
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {
|
||||
"channel": self.channel.value,
|
||||
"integration_id": self.integration_id.to_primitive(),
|
||||
"provider": self.provider.value,
|
||||
"provider_tenant_id": self.provider_tenant_id,
|
||||
"identity_id": self.identity_id.to_primitive(),
|
||||
"binding_id": self.binding_id.to_primitive() if self.binding_id is not None else None,
|
||||
"provider_user_id": self.provider_user_id,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WebEndpointPlan:
|
||||
"""Public or trusted-app web interaction surface without a saved token."""
|
||||
|
||||
@property
|
||||
def channel(self) -> HumanInputDeliveryChannel:
|
||||
return HumanInputDeliveryChannel.WEB
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"channel": self.channel.value}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConsoleEndpointPlan:
|
||||
"""Authenticated console interaction surface without a notification address."""
|
||||
|
||||
@property
|
||||
def channel(self) -> HumanInputDeliveryChannel:
|
||||
return HumanInputDeliveryChannel.CONSOLE
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"channel": self.channel.value}
|
||||
|
||||
|
||||
type DeliveryEndpointPlan = EmailEndpointPlan | IMEndpointPlan | WebEndpointPlan | ConsoleEndpointPlan
|
||||
|
||||
|
||||
class RecipientRejectionReason(StrEnum):
|
||||
"""Transport-neutral reason for rejecting one recipient source."""
|
||||
|
||||
INVALID_CONTACT_ID = "invalid_contact_id"
|
||||
CONTACT_UNAVAILABLE = "contact_unavailable"
|
||||
INVALID_DYNAMIC_SELECTOR = "invalid_dynamic_selector"
|
||||
DYNAMIC_VALUE_UNAVAILABLE = "dynamic_value_unavailable"
|
||||
UNSUPPORTED_DYNAMIC_TYPE = "unsupported_dynamic_type"
|
||||
INVALID_EMAIL = "invalid_email"
|
||||
INITIATOR_UNAVAILABLE = "initiator_unavailable"
|
||||
NO_USABLE_ENDPOINT = "no_usable_endpoint"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RejectedRecipient:
|
||||
"""Machine-readable source failure retained alongside valid approvers."""
|
||||
|
||||
source: MatchedRecipientSource
|
||||
reason: RecipientRejectionReason
|
||||
rejected_value: str | None
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {
|
||||
"source": self.source.to_primitive(),
|
||||
"reason": self.reason.value,
|
||||
"rejected_value": self.rejected_value,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResolvedApprover:
|
||||
"""One canonical subject with all matched sources and usable endpoints."""
|
||||
|
||||
subject: ApprovalSubject
|
||||
subject_key: CanonicalSubjectKey
|
||||
matched_sources: tuple[MatchedRecipientSource, ...]
|
||||
subject_snapshot: SubjectSnapshot
|
||||
endpoints: tuple[DeliveryEndpointPlan, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.matched_sources, tuple) or not isinstance(self.endpoints, tuple):
|
||||
raise TypeError("resolved approver collections must be immutable tuples")
|
||||
if self.subject_key != _canonical_key_for_subject(self.subject):
|
||||
raise ValueError("resolved approver subject key does not match its subject")
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {
|
||||
"subject": self.subject.to_primitive(),
|
||||
"subject_key": self.subject_key.value,
|
||||
"matched_sources": [source.to_primitive() for source in self.matched_sources],
|
||||
"subject_snapshot": self.subject_snapshot.to_primitive(),
|
||||
"endpoints": [endpoint.to_primitive() for endpoint in self.endpoints],
|
||||
}
|
||||
|
||||
|
||||
class RecipientResolutionFailureReason(StrEnum):
|
||||
"""Stable whole-plan failure independent from HTTP or provider semantics."""
|
||||
|
||||
NO_VALID_RECIPIENTS = "no_valid_recipients"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResolvedApprovalPlan:
|
||||
"""Immutable complete output of one recipient resolution request."""
|
||||
|
||||
approvers: tuple[ResolvedApprover, ...]
|
||||
rejected_recipients: tuple[RejectedRecipient, ...]
|
||||
failure_reason: RecipientResolutionFailureReason | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.approvers, tuple) or not isinstance(self.rejected_recipients, tuple):
|
||||
raise TypeError("approval plan collections must be immutable tuples")
|
||||
if self.approvers and self.failure_reason is not None:
|
||||
raise ValueError("a plan with approvers cannot have a failure reason")
|
||||
if not self.approvers and self.failure_reason is None:
|
||||
raise ValueError("a plan without approvers must have a failure reason")
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {
|
||||
"approvers": [approver.to_primitive() for approver in self.approvers],
|
||||
"rejected_recipients": [rejection.to_primitive() for rejection in self.rejected_recipients],
|
||||
"failure_reason": self.failure_reason.value if self.failure_reason is not None else None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactInitiatorSnapshot:
|
||||
"""Current request initiator resolved to a canonical Contact reference."""
|
||||
|
||||
contact_id: ContactId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EndUserInitiatorSnapshot:
|
||||
"""Current request initiator resolved to one app-scoped EndUser."""
|
||||
|
||||
end_user_id: EndUserId
|
||||
display_name: str | None
|
||||
email: str | None
|
||||
|
||||
|
||||
type InitiatorSnapshot = ContactInitiatorSnapshot | EndUserInitiatorSnapshot
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DebugRecipientReplacement:
|
||||
"""Valid debug actor that replaces saved recipients for one request only."""
|
||||
|
||||
subject: InitiatorSnapshot
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeliveryCapabilitySnapshot:
|
||||
"""Request-scoped effective delivery and interaction capabilities.
|
||||
|
||||
IM values are already resolved by the IM control plane; this domain never
|
||||
sees credentials, raw provider clients, or invalid binding candidates.
|
||||
Explicit Web/Console sets prevent recipient resolution from inventing
|
||||
interaction surfaces that the current runtime cannot actually expose.
|
||||
"""
|
||||
|
||||
im_bindings: tuple[EffectiveIMBindingSnapshot, ...] = ()
|
||||
contact_web_ids: frozenset[ContactId] = frozenset()
|
||||
contact_console_ids: frozenset[ContactId] = frozenset()
|
||||
end_user_web_ids: frozenset[EndUserId] = frozenset()
|
||||
email_address_web_available: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.im_bindings, tuple):
|
||||
raise TypeError("effective IM bindings must be an immutable tuple")
|
||||
if not all(
|
||||
isinstance(values, frozenset)
|
||||
for values in (self.contact_web_ids, self.contact_console_ids, self.end_user_web_ids)
|
||||
):
|
||||
raise TypeError("interaction capability identifiers must be immutable frozensets")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _PendingApprover:
|
||||
"""Private mutable accumulator hidden behind the immutable resolver result."""
|
||||
|
||||
subject: ApprovalSubject
|
||||
subject_key: CanonicalSubjectKey
|
||||
subject_snapshot: SubjectSnapshot
|
||||
first_source_position: int
|
||||
matched_sources: list[MatchedRecipientSource] = field(default_factory=list)
|
||||
endpoints: list[DeliveryEndpointPlan] = field(default_factory=list)
|
||||
|
||||
|
||||
class RecipientResolver:
|
||||
"""Resolve all recipient semantics through one deterministic domain entry."""
|
||||
|
||||
@staticmethod
|
||||
def resolve(
|
||||
*,
|
||||
specifications: tuple[RecipientSpecification, ...],
|
||||
directory: ContactDirectorySnapshot,
|
||||
dynamic_values: tuple[DynamicRecipientValue, ...],
|
||||
initiator: InitiatorSnapshot | None,
|
||||
capabilities: DeliveryCapabilitySnapshot,
|
||||
debug_replacement: DebugRecipientReplacement | None = None,
|
||||
) -> ResolvedApprovalPlan:
|
||||
"""Resolve immutable request inputs into one complete approval plan.
|
||||
|
||||
Invalid or unavailable sources are returned as typed rejection facts;
|
||||
the method raises only when a caller violates an immutable input shape.
|
||||
No database, provider, transport, or mutation side effects occur.
|
||||
"""
|
||||
if not isinstance(specifications, tuple) or not isinstance(dynamic_values, tuple):
|
||||
raise TypeError("recipient resolution inputs must be immutable tuples")
|
||||
|
||||
pending_approvers: dict[CanonicalSubjectKey, _PendingApprover] = {}
|
||||
rejected_recipients: list[RejectedRecipient] = []
|
||||
dynamic_values_by_selector: dict[tuple[str, ...], DynamicRecipientValue] = {}
|
||||
for dynamic_value in dynamic_values:
|
||||
dynamic_values_by_selector.setdefault(dynamic_value.selector, dynamic_value)
|
||||
|
||||
if debug_replacement is not None:
|
||||
source = MatchedRecipientSource(RecipientSourceKind.DEBUG_REPLACEMENT, 0, None)
|
||||
RecipientResolver._resolve_initiator(
|
||||
debug_replacement.subject,
|
||||
source,
|
||||
directory,
|
||||
capabilities,
|
||||
pending_approvers,
|
||||
rejected_recipients,
|
||||
)
|
||||
else:
|
||||
for position, specification in enumerate(specifications):
|
||||
RecipientResolver._resolve_specification(
|
||||
specification,
|
||||
position,
|
||||
directory,
|
||||
dynamic_values_by_selector,
|
||||
initiator,
|
||||
capabilities,
|
||||
pending_approvers,
|
||||
rejected_recipients,
|
||||
)
|
||||
|
||||
approvers: list[ResolvedApprover] = []
|
||||
for pending in sorted(
|
||||
pending_approvers.values(),
|
||||
key=lambda candidate: (candidate.first_source_position, candidate.subject_key.value),
|
||||
):
|
||||
matched_sources = tuple(sorted(pending.matched_sources, key=_source_sort_key))
|
||||
endpoints = tuple(sorted(pending.endpoints, key=_endpoint_sort_key))
|
||||
if not endpoints:
|
||||
rejected_recipients.extend(
|
||||
RejectedRecipient(
|
||||
source=source,
|
||||
reason=RecipientRejectionReason.NO_USABLE_ENDPOINT,
|
||||
rejected_value=pending.subject_key.value,
|
||||
)
|
||||
for source in matched_sources
|
||||
)
|
||||
continue
|
||||
approvers.append(
|
||||
ResolvedApprover(
|
||||
subject=pending.subject,
|
||||
subject_key=pending.subject_key,
|
||||
matched_sources=matched_sources,
|
||||
subject_snapshot=pending.subject_snapshot,
|
||||
endpoints=endpoints,
|
||||
)
|
||||
)
|
||||
|
||||
ordered_rejections = tuple(sorted(rejected_recipients, key=_rejection_sort_key))
|
||||
failure_reason = None if approvers else RecipientResolutionFailureReason.NO_VALID_RECIPIENTS
|
||||
return ResolvedApprovalPlan(tuple(approvers), ordered_rejections, failure_reason)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_specification(
|
||||
specification: RecipientSpecification,
|
||||
position: int,
|
||||
directory: ContactDirectorySnapshot,
|
||||
dynamic_values_by_selector: dict[tuple[str, ...], DynamicRecipientValue],
|
||||
initiator: InitiatorSnapshot | None,
|
||||
capabilities: DeliveryCapabilitySnapshot,
|
||||
pending_approvers: dict[CanonicalSubjectKey, _PendingApprover],
|
||||
rejected_recipients: list[RejectedRecipient],
|
||||
) -> None:
|
||||
if isinstance(specification, ContactRecipientSpecification):
|
||||
source = MatchedRecipientSource(
|
||||
RecipientSourceKind.STATIC_CONTACT,
|
||||
position,
|
||||
specification.contact_id,
|
||||
)
|
||||
try:
|
||||
contact_id = ContactId(specification.contact_id)
|
||||
except ValueError:
|
||||
rejected_recipients.append(
|
||||
RejectedRecipient(source, RecipientRejectionReason.INVALID_CONTACT_ID, specification.contact_id)
|
||||
)
|
||||
return
|
||||
RecipientResolver._resolve_contact(
|
||||
contact_id,
|
||||
source,
|
||||
directory,
|
||||
capabilities,
|
||||
pending_approvers,
|
||||
rejected_recipients,
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(specification, OneTimeEmailRecipientSpecification):
|
||||
source = MatchedRecipientSource(
|
||||
RecipientSourceKind.ONE_TIME_EMAIL,
|
||||
position,
|
||||
specification.email,
|
||||
)
|
||||
RecipientResolver._resolve_email(
|
||||
specification.email,
|
||||
source,
|
||||
directory,
|
||||
capabilities,
|
||||
pending_approvers,
|
||||
rejected_recipients,
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(specification, DynamicEmailRecipientSpecification):
|
||||
selector_reference = ".".join(specification.selector)
|
||||
source = MatchedRecipientSource(
|
||||
RecipientSourceKind.DYNAMIC_EMAIL,
|
||||
position,
|
||||
selector_reference,
|
||||
)
|
||||
if not specification.selector or any(not component.strip() for component in specification.selector):
|
||||
rejected_recipients.append(
|
||||
RejectedRecipient(source, RecipientRejectionReason.INVALID_DYNAMIC_SELECTOR, selector_reference)
|
||||
)
|
||||
return
|
||||
dynamic_value = dynamic_values_by_selector.get(specification.selector)
|
||||
if dynamic_value is None:
|
||||
rejected_recipients.append(
|
||||
RejectedRecipient(source, RecipientRejectionReason.DYNAMIC_VALUE_UNAVAILABLE, selector_reference)
|
||||
)
|
||||
return
|
||||
if isinstance(dynamic_value.value, UnsupportedDynamicRecipientValue):
|
||||
rejected_recipients.append(
|
||||
RejectedRecipient(
|
||||
source,
|
||||
RecipientRejectionReason.UNSUPPORTED_DYNAMIC_TYPE,
|
||||
dynamic_value.value.value_type,
|
||||
)
|
||||
)
|
||||
return
|
||||
RecipientResolver._resolve_email(
|
||||
dynamic_value.value,
|
||||
source,
|
||||
directory,
|
||||
capabilities,
|
||||
pending_approvers,
|
||||
rejected_recipients,
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(specification, CurrentInitiatorRecipientSpecification):
|
||||
source = MatchedRecipientSource(RecipientSourceKind.CURRENT_INITIATOR, position, None)
|
||||
if initiator is None:
|
||||
rejected_recipients.append(
|
||||
RejectedRecipient(source, RecipientRejectionReason.INITIATOR_UNAVAILABLE, None)
|
||||
)
|
||||
return
|
||||
RecipientResolver._resolve_initiator(
|
||||
initiator,
|
||||
source,
|
||||
directory,
|
||||
capabilities,
|
||||
pending_approvers,
|
||||
rejected_recipients,
|
||||
)
|
||||
return
|
||||
|
||||
assert_never(specification)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_initiator(
|
||||
initiator: InitiatorSnapshot,
|
||||
source: MatchedRecipientSource,
|
||||
directory: ContactDirectorySnapshot,
|
||||
capabilities: DeliveryCapabilitySnapshot,
|
||||
pending_approvers: dict[CanonicalSubjectKey, _PendingApprover],
|
||||
rejected_recipients: list[RejectedRecipient],
|
||||
) -> None:
|
||||
if isinstance(initiator, ContactInitiatorSnapshot):
|
||||
RecipientResolver._resolve_contact(
|
||||
initiator.contact_id,
|
||||
source,
|
||||
directory,
|
||||
capabilities,
|
||||
pending_approvers,
|
||||
rejected_recipients,
|
||||
)
|
||||
return
|
||||
|
||||
normalized_email: NormalizedEmail | None = None
|
||||
if initiator.email is not None:
|
||||
try:
|
||||
normalized_email = NormalizedEmail(initiator.email)
|
||||
except ValueError:
|
||||
normalized_email = None
|
||||
subject = EndUserApprovalSubject(initiator.end_user_id)
|
||||
endpoints: list[DeliveryEndpointPlan] = []
|
||||
if normalized_email is not None:
|
||||
endpoints.append(EmailEndpointPlan(normalized_email))
|
||||
if initiator.end_user_id in capabilities.end_user_web_ids:
|
||||
endpoints.append(WebEndpointPlan())
|
||||
RecipientResolver._add_approver(
|
||||
subject,
|
||||
source,
|
||||
SubjectSnapshot(initiator.display_name, normalized_email.value if normalized_email is not None else None),
|
||||
endpoints,
|
||||
pending_approvers,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_contact(
|
||||
contact_id: ContactId,
|
||||
source: MatchedRecipientSource,
|
||||
directory: ContactDirectorySnapshot,
|
||||
capabilities: DeliveryCapabilitySnapshot,
|
||||
pending_approvers: dict[CanonicalSubjectKey, _PendingApprover],
|
||||
rejected_recipients: list[RejectedRecipient],
|
||||
) -> None:
|
||||
try:
|
||||
resolution = ContactDirectoryPolicy.resolve_for_workspace(directory, contact_id)
|
||||
except ContactDirectoryError:
|
||||
resolution = ContactResolution.ABSENT
|
||||
contact = directory.find(contact_id)
|
||||
if resolution is ContactResolution.ABSENT or contact is None:
|
||||
rejected_recipients.append(
|
||||
RejectedRecipient(source, RecipientRejectionReason.CONTACT_UNAVAILABLE, contact_id.value)
|
||||
)
|
||||
return
|
||||
RecipientResolver._add_contact_approver(contact, source, capabilities, pending_approvers)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_email(
|
||||
email: str,
|
||||
source: MatchedRecipientSource,
|
||||
directory: ContactDirectorySnapshot,
|
||||
capabilities: DeliveryCapabilitySnapshot,
|
||||
pending_approvers: dict[CanonicalSubjectKey, _PendingApprover],
|
||||
rejected_recipients: list[RejectedRecipient],
|
||||
) -> None:
|
||||
"""Resolve Email input without bypassing an existing Contact's policy.
|
||||
|
||||
EmailAddress authority is valid only when the directory contains no
|
||||
matching Contact. A matching but unavailable Contact fails closed so
|
||||
callers cannot bypass Account availability or workspace visibility.
|
||||
"""
|
||||
try:
|
||||
normalized_email = NormalizedEmail(email)
|
||||
except ValueError:
|
||||
rejected_recipients.append(RejectedRecipient(source, RecipientRejectionReason.INVALID_EMAIL, email))
|
||||
return
|
||||
|
||||
matching_contacts = sorted(
|
||||
(contact for contact in directory.contacts if contact.normalized_email == normalized_email),
|
||||
key=lambda contact: contact.id.value,
|
||||
)
|
||||
for contact in matching_contacts:
|
||||
try:
|
||||
resolution = ContactDirectoryPolicy.resolve_for_workspace(directory, contact.id)
|
||||
except ContactDirectoryError:
|
||||
continue
|
||||
if resolution is not ContactResolution.ABSENT:
|
||||
RecipientResolver._add_contact_approver(contact, source, capabilities, pending_approvers)
|
||||
return
|
||||
|
||||
if matching_contacts:
|
||||
rejected_recipients.append(
|
||||
RejectedRecipient(
|
||||
source,
|
||||
RecipientRejectionReason.CONTACT_UNAVAILABLE,
|
||||
normalized_email.value,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
subject = EmailAddressApprovalSubject(normalized_email)
|
||||
endpoints: list[DeliveryEndpointPlan] = [EmailEndpointPlan(normalized_email)]
|
||||
if capabilities.email_address_web_available:
|
||||
endpoints.append(WebEndpointPlan())
|
||||
RecipientResolver._add_approver(
|
||||
subject,
|
||||
source,
|
||||
SubjectSnapshot(None, normalized_email.value),
|
||||
endpoints,
|
||||
pending_approvers,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _add_contact_approver(
|
||||
contact: Contact,
|
||||
source: MatchedRecipientSource,
|
||||
capabilities: DeliveryCapabilitySnapshot,
|
||||
pending_approvers: dict[CanonicalSubjectKey, _PendingApprover],
|
||||
) -> None:
|
||||
endpoints: list[DeliveryEndpointPlan] = []
|
||||
if contact.normalized_email is not None:
|
||||
endpoints.append(EmailEndpointPlan(contact.normalized_email))
|
||||
endpoints.extend(
|
||||
IMEndpointPlan(
|
||||
integration_id=binding.integration_id,
|
||||
provider=binding.provider,
|
||||
provider_tenant_id=binding.provider_tenant_id,
|
||||
identity_id=binding.identity_id,
|
||||
binding_id=binding.binding_id,
|
||||
provider_user_id=binding.provider_user_id,
|
||||
)
|
||||
for binding in capabilities.im_bindings
|
||||
if binding.contact_id == contact.id
|
||||
)
|
||||
if contact.id in capabilities.contact_web_ids:
|
||||
endpoints.append(WebEndpointPlan())
|
||||
if contact.id in capabilities.contact_console_ids:
|
||||
endpoints.append(ConsoleEndpointPlan())
|
||||
RecipientResolver._add_approver(
|
||||
ContactApprovalSubject(contact.id),
|
||||
source,
|
||||
SubjectSnapshot(contact.name, contact.email),
|
||||
endpoints,
|
||||
pending_approvers,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _add_approver(
|
||||
subject: ApprovalSubject,
|
||||
source: MatchedRecipientSource,
|
||||
subject_snapshot: SubjectSnapshot,
|
||||
endpoints: list[DeliveryEndpointPlan],
|
||||
pending_approvers: dict[CanonicalSubjectKey, _PendingApprover],
|
||||
) -> None:
|
||||
subject_key = _canonical_key_for_subject(subject)
|
||||
pending = pending_approvers.get(subject_key)
|
||||
if pending is None:
|
||||
pending = _PendingApprover(
|
||||
subject=subject,
|
||||
subject_key=subject_key,
|
||||
subject_snapshot=subject_snapshot,
|
||||
first_source_position=source.position,
|
||||
)
|
||||
pending_approvers[subject_key] = pending
|
||||
if source not in pending.matched_sources:
|
||||
pending.matched_sources.append(source)
|
||||
for endpoint in endpoints:
|
||||
if endpoint not in pending.endpoints:
|
||||
pending.endpoints.append(endpoint)
|
||||
|
||||
|
||||
def _canonical_key_for_subject(subject: ApprovalSubject) -> CanonicalSubjectKey:
|
||||
if isinstance(subject, ContactApprovalSubject):
|
||||
return CanonicalSubjectKey.for_contact(subject.contact_id)
|
||||
if isinstance(subject, EndUserApprovalSubject):
|
||||
return CanonicalSubjectKey.for_end_user(subject.end_user_id)
|
||||
return CanonicalSubjectKey.for_email(subject.normalized_email)
|
||||
|
||||
|
||||
_CHANNEL_ORDER = {
|
||||
HumanInputDeliveryChannel.EMAIL: 0,
|
||||
HumanInputDeliveryChannel.IM: 1,
|
||||
HumanInputDeliveryChannel.WEB: 2,
|
||||
HumanInputDeliveryChannel.CONSOLE: 3,
|
||||
}
|
||||
|
||||
|
||||
def _source_sort_key(source: MatchedRecipientSource) -> tuple[int, str, str]:
|
||||
return source.position, source.kind.value, source.reference or ""
|
||||
|
||||
|
||||
def _endpoint_sort_key(endpoint: DeliveryEndpointPlan) -> tuple[int, str, str, str]:
|
||||
channel_order = _CHANNEL_ORDER[endpoint.channel]
|
||||
if isinstance(endpoint, EmailEndpointPlan):
|
||||
return channel_order, endpoint.email_address.value, "", ""
|
||||
if isinstance(endpoint, IMEndpointPlan):
|
||||
return (
|
||||
channel_order,
|
||||
endpoint.integration_id.value,
|
||||
endpoint.provider.value,
|
||||
endpoint.identity_id.value,
|
||||
)
|
||||
return channel_order, "", "", ""
|
||||
|
||||
|
||||
def _rejection_sort_key(rejection: RejectedRecipient) -> tuple[int, str, str, str]:
|
||||
source_key = _source_sort_key(rejection.source)
|
||||
return source_key[0], source_key[1], rejection.reason.value, rejection.rejected_value or ""
|
||||
@@ -1,145 +0,0 @@
|
||||
"""Immutable recipient specifications at the workflow-to-approval boundary.
|
||||
|
||||
Saved node configuration intentionally retains unvalidated Email text. Runtime
|
||||
validation belongs to :class:`RecipientResolver`, which can retain a typed
|
||||
rejection without making workflow configuration parsing fail early.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import assert_never
|
||||
|
||||
from core.workflow.nodes.human_input_v2.entities import (
|
||||
Contact as WorkflowContactRecipient,
|
||||
)
|
||||
from core.workflow.nodes.human_input_v2.entities import (
|
||||
DynamicEmail as WorkflowDynamicEmailRecipient,
|
||||
)
|
||||
from core.workflow.nodes.human_input_v2.entities import (
|
||||
HumanInputNodeData,
|
||||
)
|
||||
from core.workflow.nodes.human_input_v2.entities import (
|
||||
Initiator as WorkflowInitiatorRecipient,
|
||||
)
|
||||
from core.workflow.nodes.human_input_v2.entities import (
|
||||
OnetimeEmail as WorkflowOneTimeEmailRecipient,
|
||||
)
|
||||
|
||||
|
||||
class RecipientSpecificationKind(StrEnum):
|
||||
"""Stable workflow recipient discriminator."""
|
||||
|
||||
CONTACT = "contact"
|
||||
DYNAMIC_EMAIL = "dynamic_email"
|
||||
ONETIME_EMAIL = "onetime_email"
|
||||
INITIATOR = "initiator"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactRecipientSpecification:
|
||||
"""Saved reference to one Contact; current availability is resolved later."""
|
||||
|
||||
contact_id: str
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"type": RecipientSpecificationKind.CONTACT.value, "contact_id": self.contact_id}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OneTimeEmailRecipientSpecification:
|
||||
"""Saved one-time Email text whose validity is decided per resolution."""
|
||||
|
||||
email: str
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"type": RecipientSpecificationKind.ONETIME_EMAIL.value, "email": self.email}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DynamicEmailRecipientSpecification:
|
||||
"""Saved workflow selector whose current value is supplied separately."""
|
||||
|
||||
selector: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.selector, tuple):
|
||||
raise TypeError("dynamic email selector must be an immutable tuple")
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"type": RecipientSpecificationKind.DYNAMIC_EMAIL.value, "selector": list(self.selector)}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CurrentInitiatorRecipientSpecification:
|
||||
"""Request-scoped current initiator recipient marker."""
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"type": RecipientSpecificationKind.INITIATOR.value}
|
||||
|
||||
|
||||
type RecipientSpecification = (
|
||||
ContactRecipientSpecification
|
||||
| OneTimeEmailRecipientSpecification
|
||||
| DynamicEmailRecipientSpecification
|
||||
| CurrentInitiatorRecipientSpecification
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnsupportedDynamicRecipientValue:
|
||||
"""Safe snapshot of a non-string workflow value without retaining its graph."""
|
||||
|
||||
value_type: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.value_type:
|
||||
raise ValueError("unsupported dynamic recipient value type must not be blank")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DynamicRecipientValue:
|
||||
"""One evaluated selector value captured for a single resolution request."""
|
||||
|
||||
selector: tuple[str, ...]
|
||||
value: str | UnsupportedDynamicRecipientValue
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.selector, tuple):
|
||||
raise TypeError("dynamic recipient value selector must be an immutable tuple")
|
||||
|
||||
@classmethod
|
||||
def from_runtime(cls, selector: tuple[str, ...], value: object) -> DynamicRecipientValue:
|
||||
"""Capture a runtime value without retaining mutable unsupported data."""
|
||||
|
||||
captured_value: str | UnsupportedDynamicRecipientValue
|
||||
if isinstance(value, str):
|
||||
captured_value = value
|
||||
else:
|
||||
captured_value = UnsupportedDynamicRecipientValue(type(value).__name__)
|
||||
return cls(selector=selector, value=captured_value)
|
||||
|
||||
|
||||
class WorkflowRecipientSpecificationAdapter:
|
||||
"""Convert versioned workflow node values into approval-domain inputs."""
|
||||
|
||||
@staticmethod
|
||||
def from_node_data(node_data: HumanInputNodeData) -> tuple[RecipientSpecification, ...]:
|
||||
"""Copy ordered v2 node recipients into immutable domain values."""
|
||||
|
||||
specifications: list[RecipientSpecification] = []
|
||||
for configured_recipient in node_data.recipients_spec:
|
||||
specification: RecipientSpecification
|
||||
if isinstance(configured_recipient, WorkflowContactRecipient):
|
||||
specification = ContactRecipientSpecification(contact_id=configured_recipient.contact_id)
|
||||
elif isinstance(configured_recipient, WorkflowOneTimeEmailRecipient):
|
||||
specification = OneTimeEmailRecipientSpecification(email=configured_recipient.email)
|
||||
elif isinstance(configured_recipient, WorkflowDynamicEmailRecipient):
|
||||
specification = DynamicEmailRecipientSpecification(selector=tuple(configured_recipient.selector))
|
||||
elif isinstance(configured_recipient, WorkflowInitiatorRecipient):
|
||||
specification = CurrentInitiatorRecipientSpecification()
|
||||
else:
|
||||
assert_never(configured_recipient)
|
||||
specifications.append(specification)
|
||||
return tuple(specifications)
|
||||
@@ -1,405 +0,0 @@
|
||||
"""Pure current-state authorization for Human Input v2 submissions.
|
||||
|
||||
The module owns the cross-snapshot decision only. Callers must verify transport
|
||||
credentials before constructing a proof and must load one coherent
|
||||
``AuthorizationContext`` through persistence. The authorizer performs no I/O,
|
||||
does not retain raw credentials, and never reloads Contact or IM binding facts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import assert_never
|
||||
|
||||
from core.human_input_v2.entities import IMProvider
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
AppId,
|
||||
ContactId,
|
||||
EndUserId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IntegrationId,
|
||||
NormalizedEmail,
|
||||
UtcTimestamp,
|
||||
)
|
||||
|
||||
from .delivery import DeliveryEndpoint
|
||||
from .form import (
|
||||
FormInactiveReason,
|
||||
HumanInputForm,
|
||||
InactiveFormState,
|
||||
InvalidApproverGrantError,
|
||||
InvalidSelectedActionError,
|
||||
SubmissionTransitionDecision,
|
||||
)
|
||||
from .grants import ApproverGrant, DeliveryEndpointRef
|
||||
from .otp import ContactOTPSubject, EmailAddressOTPSubject, VerifiedEmailOTPProof
|
||||
from .recipient_resolution import ContactApprovalSubject, EmailAddressApprovalSubject, EndUserApprovalSubject
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VerifiedAccountSessionProof:
|
||||
"""Current Account identity produced by a trusted session verifier."""
|
||||
|
||||
account_id: AccountId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VerifiedTrustedEndUserProof:
|
||||
"""Current EndUser identity produced by a trusted app-token boundary."""
|
||||
|
||||
end_user_id: EndUserId
|
||||
app_id: AppId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VerifiedIMIdentityProof:
|
||||
"""Current provider identity evidence without callback credentials or payloads."""
|
||||
|
||||
integration_id: IntegrationId
|
||||
identity_id: IMIdentityId
|
||||
binding_id: IMBindingId | None
|
||||
provider: IMProvider
|
||||
provider_tenant_id: str
|
||||
provider_user_id: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.provider_tenant_id.strip() or not self.provider_user_id.strip():
|
||||
raise ValueError("verified IM provider identities must not be blank")
|
||||
|
||||
|
||||
type VerifiedSubmissionProof = (
|
||||
VerifiedAccountSessionProof | VerifiedTrustedEndUserProof | VerifiedEmailOTPProof | VerifiedIMIdentityProof
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccountSubmissionActor:
|
||||
"""Current Dify Account that completed a submission."""
|
||||
|
||||
account_id: AccountId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EndUserSubmissionActor:
|
||||
"""Current app-scoped EndUser that completed a submission."""
|
||||
|
||||
end_user_id: EndUserId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EmailAddressSubmissionActor:
|
||||
"""Verified normalized Email identity that completed a submission."""
|
||||
|
||||
normalized_email: NormalizedEmail
|
||||
|
||||
|
||||
type SubmissionActor = AccountSubmissionActor | EndUserSubmissionActor | EmailAddressSubmissionActor
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CurrentContactAuthorizationFacts:
|
||||
"""Current Contact incarnation, Email, Account, and workspace availability."""
|
||||
|
||||
contact_id: ContactId
|
||||
account_id: AccountId | None
|
||||
normalized_email: NormalizedEmail | None
|
||||
account_active: bool
|
||||
workspace_available: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CurrentEndUserAuthorizationFacts:
|
||||
"""Current tenant/app ownership facts for one EndUser identity."""
|
||||
|
||||
end_user_id: EndUserId
|
||||
app_id: AppId
|
||||
workspace_available: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CurrentIMAuthorizationFacts:
|
||||
"""Credential-free effective IM binding observed in the authorization snapshot."""
|
||||
|
||||
integration_id: IntegrationId
|
||||
provider: IMProvider
|
||||
provider_tenant_id: str
|
||||
contact_id: ContactId
|
||||
account_id: AccountId | None
|
||||
identity_id: IMIdentityId
|
||||
binding_id: IMBindingId | None
|
||||
provider_user_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthorizationContext:
|
||||
"""One immutable tenant-scoped view used without later identity reloads."""
|
||||
|
||||
form: HumanInputForm
|
||||
grant: ApproverGrant
|
||||
endpoint: DeliveryEndpoint | None
|
||||
current_contact: CurrentContactAuthorizationFacts | None
|
||||
current_end_user: CurrentEndUserAuthorizationFacts | None
|
||||
current_im_binding: CurrentIMAuthorizationFacts | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.grant.ref.form_ref != self.form.ref or self.grant not in self.form.grants:
|
||||
raise ValueError("authorization grant does not belong to the form snapshot")
|
||||
if self.endpoint is not None and self.endpoint.grant_ref != self.grant.ref:
|
||||
raise ValueError("authorization endpoint does not belong to the target grant")
|
||||
|
||||
|
||||
class SubmissionAuthorizationRejection(StrEnum):
|
||||
"""Stable transport-neutral reasons for denied submission authority."""
|
||||
|
||||
RAW_CREDENTIAL_NOT_VERIFIED = "raw_credential_not_verified"
|
||||
FORM_ALREADY_SUBMITTED = "form_already_submitted"
|
||||
FORM_TIMED_OUT = "form_timed_out"
|
||||
FORM_STATUS_EXPIRED = "form_status_expired"
|
||||
FORM_GLOBALLY_EXPIRED = "form_globally_expired"
|
||||
GRANT_NOT_MATCHED = "grant_not_matched"
|
||||
INVALID_SELECTED_ACTION = "invalid_selected_action"
|
||||
STALE_IDENTITY = "stale_identity"
|
||||
ACCOUNT_DISABLED = "account_disabled"
|
||||
WORKSPACE_UNAVAILABLE = "workspace_unavailable"
|
||||
END_USER_UNAVAILABLE = "end_user_unavailable"
|
||||
IM_BINDING_CHANGED = "im_binding_changed"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthorizedSubmission:
|
||||
"""Current authority and local form transition prepared for atomic persistence."""
|
||||
|
||||
transition: SubmissionTransitionDecision
|
||||
proof: VerifiedSubmissionProof
|
||||
actor: SubmissionActor
|
||||
endpoint_ref: DeliveryEndpointRef | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SubmissionAuthorizationDecision:
|
||||
"""Exactly one authorized value or stable rejection."""
|
||||
|
||||
authorized: AuthorizedSubmission | None
|
||||
rejection: SubmissionAuthorizationRejection | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (self.authorized is None) == (self.rejection is None):
|
||||
raise ValueError("authorization decision requires exactly one outcome")
|
||||
|
||||
@classmethod
|
||||
def accept(cls, authorized: AuthorizedSubmission) -> SubmissionAuthorizationDecision:
|
||||
return cls(authorized, None)
|
||||
|
||||
@classmethod
|
||||
def reject(cls, reason: SubmissionAuthorizationRejection) -> SubmissionAuthorizationDecision:
|
||||
return cls(None, reason)
|
||||
|
||||
|
||||
class SubmissionAuthorizer:
|
||||
"""Stateless cross-snapshot policy that resolves one current business actor."""
|
||||
|
||||
@classmethod
|
||||
def authorize(
|
||||
cls,
|
||||
*,
|
||||
context: AuthorizationContext,
|
||||
proof: object,
|
||||
selected_action_id: str,
|
||||
now: UtcTimestamp,
|
||||
) -> SubmissionAuthorizationDecision:
|
||||
"""Authorize verified proof against one already-loaded coherent context."""
|
||||
|
||||
if not isinstance(
|
||||
proof,
|
||||
VerifiedAccountSessionProof | VerifiedTrustedEndUserProof | VerifiedEmailOTPProof | VerifiedIMIdentityProof,
|
||||
):
|
||||
return SubmissionAuthorizationDecision.reject(SubmissionAuthorizationRejection.RAW_CREDENTIAL_NOT_VERIFIED)
|
||||
|
||||
state = context.form.state_at(now)
|
||||
if isinstance(state, InactiveFormState):
|
||||
return SubmissionAuthorizationDecision.reject(cls._inactive_rejection(state.reason))
|
||||
try:
|
||||
transition = context.form.decide_submission(
|
||||
grant_id=context.grant.id,
|
||||
selected_action_id=selected_action_id,
|
||||
now=now,
|
||||
)
|
||||
except InvalidApproverGrantError:
|
||||
return SubmissionAuthorizationDecision.reject(SubmissionAuthorizationRejection.GRANT_NOT_MATCHED)
|
||||
except InvalidSelectedActionError:
|
||||
return SubmissionAuthorizationDecision.reject(SubmissionAuthorizationRejection.INVALID_SELECTED_ACTION)
|
||||
if isinstance(transition, InactiveFormState):
|
||||
return SubmissionAuthorizationDecision.reject(cls._inactive_rejection(transition.reason))
|
||||
|
||||
actor_or_rejection = cls._resolve_actor(context, proof)
|
||||
if isinstance(actor_or_rejection, SubmissionAuthorizationRejection):
|
||||
return SubmissionAuthorizationDecision.reject(actor_or_rejection)
|
||||
return SubmissionAuthorizationDecision.accept(
|
||||
AuthorizedSubmission(
|
||||
transition=transition,
|
||||
proof=proof,
|
||||
actor=actor_or_rejection,
|
||||
endpoint_ref=context.endpoint.ref if context.endpoint is not None else None,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _inactive_rejection(reason: FormInactiveReason) -> SubmissionAuthorizationRejection:
|
||||
match reason:
|
||||
case FormInactiveReason.SUBMITTED:
|
||||
return SubmissionAuthorizationRejection.FORM_ALREADY_SUBMITTED
|
||||
case FormInactiveReason.TIMED_OUT:
|
||||
return SubmissionAuthorizationRejection.FORM_TIMED_OUT
|
||||
case FormInactiveReason.STATUS_EXPIRED:
|
||||
return SubmissionAuthorizationRejection.FORM_STATUS_EXPIRED
|
||||
case FormInactiveReason.GLOBALLY_EXPIRED:
|
||||
return SubmissionAuthorizationRejection.FORM_GLOBALLY_EXPIRED
|
||||
assert_never(reason)
|
||||
|
||||
@classmethod
|
||||
def _resolve_actor(
|
||||
cls,
|
||||
context: AuthorizationContext,
|
||||
proof: VerifiedSubmissionProof,
|
||||
) -> SubmissionActor | SubmissionAuthorizationRejection:
|
||||
match proof:
|
||||
case VerifiedAccountSessionProof():
|
||||
return cls._authorize_account(context, proof)
|
||||
case VerifiedTrustedEndUserProof():
|
||||
return cls._authorize_end_user(context, proof)
|
||||
case VerifiedEmailOTPProof():
|
||||
return cls._authorize_email(context, proof)
|
||||
case VerifiedIMIdentityProof():
|
||||
return cls._authorize_im(context, proof)
|
||||
assert_never(proof)
|
||||
|
||||
@classmethod
|
||||
def _authorize_account(
|
||||
cls,
|
||||
context: AuthorizationContext,
|
||||
proof: VerifiedAccountSessionProof,
|
||||
) -> SubmissionActor | SubmissionAuthorizationRejection:
|
||||
if not isinstance(context.grant.subject, ContactApprovalSubject):
|
||||
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
current = cls._validate_current_contact(context, context.grant.subject.contact_id)
|
||||
if isinstance(current, SubmissionAuthorizationRejection):
|
||||
return current
|
||||
if current.account_id != proof.account_id:
|
||||
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
return AccountSubmissionActor(proof.account_id)
|
||||
|
||||
@staticmethod
|
||||
def _authorize_end_user(
|
||||
context: AuthorizationContext,
|
||||
proof: VerifiedTrustedEndUserProof,
|
||||
) -> SubmissionActor | SubmissionAuthorizationRejection:
|
||||
if not isinstance(context.grant.subject, EndUserApprovalSubject):
|
||||
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
current = context.current_end_user
|
||||
if (
|
||||
current is None
|
||||
or current.end_user_id != context.grant.subject.end_user_id
|
||||
or current.end_user_id != proof.end_user_id
|
||||
or current.app_id != context.form.app_id
|
||||
or current.app_id != proof.app_id
|
||||
):
|
||||
return SubmissionAuthorizationRejection.END_USER_UNAVAILABLE
|
||||
if not current.workspace_available:
|
||||
return SubmissionAuthorizationRejection.WORKSPACE_UNAVAILABLE
|
||||
return EndUserSubmissionActor(current.end_user_id)
|
||||
|
||||
@classmethod
|
||||
def _authorize_email(
|
||||
cls,
|
||||
context: AuthorizationContext,
|
||||
proof: VerifiedEmailOTPProof,
|
||||
) -> SubmissionActor | SubmissionAuthorizationRejection:
|
||||
if proof.challenge_ref.grant_ref != context.grant.ref:
|
||||
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
subject = context.grant.subject
|
||||
if isinstance(subject, EmailAddressApprovalSubject):
|
||||
if (
|
||||
not isinstance(proof.subject, EmailAddressOTPSubject)
|
||||
or proof.subject.normalized_email != subject.normalized_email
|
||||
or proof.normalized_email != subject.normalized_email
|
||||
):
|
||||
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
return EmailAddressSubmissionActor(subject.normalized_email)
|
||||
if not isinstance(subject, ContactApprovalSubject) or not isinstance(proof.subject, ContactOTPSubject):
|
||||
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
if proof.subject.contact_id != subject.contact_id:
|
||||
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
current = cls._validate_current_contact(context, subject.contact_id)
|
||||
if isinstance(current, SubmissionAuthorizationRejection):
|
||||
return current
|
||||
if current.normalized_email is None or current.normalized_email != proof.normalized_email:
|
||||
return SubmissionAuthorizationRejection.STALE_IDENTITY
|
||||
if current.account_id is not None:
|
||||
return AccountSubmissionActor(current.account_id)
|
||||
return EmailAddressSubmissionActor(proof.normalized_email)
|
||||
|
||||
@classmethod
|
||||
def _authorize_im(
|
||||
cls,
|
||||
context: AuthorizationContext,
|
||||
proof: VerifiedIMIdentityProof,
|
||||
) -> SubmissionActor | SubmissionAuthorizationRejection:
|
||||
if not isinstance(context.grant.subject, ContactApprovalSubject):
|
||||
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
current_contact = cls._validate_current_contact(context, context.grant.subject.contact_id)
|
||||
if isinstance(current_contact, SubmissionAuthorizationRejection):
|
||||
return current_contact
|
||||
if current_contact.account_id is None:
|
||||
return SubmissionAuthorizationRejection.STALE_IDENTITY
|
||||
current_im = context.current_im_binding
|
||||
if current_im is None:
|
||||
return SubmissionAuthorizationRejection.IM_BINDING_CHANGED
|
||||
if (
|
||||
current_im.contact_id != context.grant.subject.contact_id
|
||||
or current_im.account_id != current_contact.account_id
|
||||
or current_im.integration_id != proof.integration_id
|
||||
or current_im.identity_id != proof.identity_id
|
||||
or current_im.binding_id != proof.binding_id
|
||||
or current_im.provider is not proof.provider
|
||||
or current_im.provider_tenant_id != proof.provider_tenant_id
|
||||
or current_im.provider_user_id != proof.provider_user_id
|
||||
):
|
||||
return SubmissionAuthorizationRejection.IM_BINDING_CHANGED
|
||||
return AccountSubmissionActor(current_contact.account_id)
|
||||
|
||||
@staticmethod
|
||||
def _validate_current_contact(
|
||||
context: AuthorizationContext,
|
||||
contact_id: ContactId,
|
||||
) -> CurrentContactAuthorizationFacts | SubmissionAuthorizationRejection:
|
||||
current = context.current_contact
|
||||
if current is None or current.contact_id != contact_id:
|
||||
return SubmissionAuthorizationRejection.STALE_IDENTITY
|
||||
if current.account_id is not None and not current.account_active:
|
||||
return SubmissionAuthorizationRejection.ACCOUNT_DISABLED
|
||||
if not current.workspace_available:
|
||||
return SubmissionAuthorizationRejection.WORKSPACE_UNAVAILABLE
|
||||
return current
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AccountSubmissionActor",
|
||||
"AuthorizationContext",
|
||||
"AuthorizedSubmission",
|
||||
"CurrentContactAuthorizationFacts",
|
||||
"CurrentEndUserAuthorizationFacts",
|
||||
"CurrentIMAuthorizationFacts",
|
||||
"EmailAddressSubmissionActor",
|
||||
"EndUserSubmissionActor",
|
||||
"SubmissionActor",
|
||||
"SubmissionAuthorizationDecision",
|
||||
"SubmissionAuthorizationRejection",
|
||||
"SubmissionAuthorizer",
|
||||
"VerifiedAccountSessionProof",
|
||||
"VerifiedIMIdentityProof",
|
||||
"VerifiedSubmissionProof",
|
||||
"VerifiedTrustedEndUserProof",
|
||||
]
|
||||
@@ -1,117 +0,0 @@
|
||||
"""Transaction-oriented ports for current authorization and first-success commit.
|
||||
|
||||
The transaction object keeps one coherent authorization context alive through
|
||||
the winning write set. Generic CRUD is intentionally absent; persistence owns
|
||||
the Form lock, rejection audit append, and atomic authorized commit shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import AbstractContextManager
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Protocol
|
||||
|
||||
from core.human_input_v2.shared import (
|
||||
ApproverGrantId,
|
||||
AuditEventId,
|
||||
DeliveryEndpointId,
|
||||
SubmissionId,
|
||||
UtcTimestamp,
|
||||
)
|
||||
|
||||
from .frozen_values import FrozenJSONObject
|
||||
from .grants import FormRef
|
||||
from .submission_authorization import AuthorizationContext, AuthorizedSubmission
|
||||
from .submission_records import FormAuthorizationAuditEvent, FormSubmission
|
||||
|
||||
|
||||
class RetryableSubmissionPersistenceError(RuntimeError):
|
||||
"""A complete submission transaction must be retried with a fresh snapshot."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SubmissionAttemptScope:
|
||||
"""Complete logical owner chain selected before the transaction begins."""
|
||||
|
||||
form_ref: FormRef
|
||||
approver_grant_id: ApproverGrantId
|
||||
endpoint_id: DeliveryEndpointId | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthorizedSubmissionCommit:
|
||||
"""Caller-owned identities and structured values for one authorized write set."""
|
||||
|
||||
submission_id: SubmissionId
|
||||
authorization_audit_event_id: AuditEventId
|
||||
authorized: AuthorizedSubmission
|
||||
input_snapshot: FrozenJSONObject
|
||||
canonical_values: FrozenJSONObject
|
||||
|
||||
def to_submission(
|
||||
self,
|
||||
*,
|
||||
form_ref: FormRef,
|
||||
approver_grant_id: ApproverGrantId,
|
||||
endpoint_id: DeliveryEndpointId | None,
|
||||
submitted_at: UtcTimestamp,
|
||||
) -> FormSubmission:
|
||||
"""Build the immutable record value after owner-scope validation."""
|
||||
|
||||
return FormSubmission(
|
||||
id=self.submission_id,
|
||||
form_ref=form_ref,
|
||||
approver_grant_id=approver_grant_id,
|
||||
endpoint_id=endpoint_id,
|
||||
authorization_audit_event_id=self.authorization_audit_event_id,
|
||||
actor=self.authorized.actor,
|
||||
selected_action_id=self.authorized.transition.selected_action_id,
|
||||
input_snapshot=self.input_snapshot,
|
||||
canonical_values=self.canonical_values,
|
||||
submitted_at=submitted_at,
|
||||
created_at=submitted_at,
|
||||
updated_at=submitted_at,
|
||||
)
|
||||
|
||||
|
||||
class SubmissionCommitStatus(StrEnum):
|
||||
"""Stable first-success persistence outcome."""
|
||||
|
||||
COMMITTED = "committed"
|
||||
ALREADY_COMPLETED = "already_completed"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SubmissionCommitResult:
|
||||
"""Committed winning submission or stable loser result."""
|
||||
|
||||
status: SubmissionCommitStatus
|
||||
submission: FormSubmission | None
|
||||
|
||||
|
||||
class SubmissionTransaction(Protocol):
|
||||
"""One session-bound authorization and commit transaction."""
|
||||
|
||||
def load_authorization_context(self, *, proof: object) -> AuthorizationContext: ...
|
||||
|
||||
def append_rejection_audit(self, event: FormAuthorizationAuditEvent) -> None: ...
|
||||
|
||||
def commit_authorized_submission_once(self, commit: AuthorizedSubmissionCommit) -> SubmissionCommitResult: ...
|
||||
|
||||
|
||||
class SubmissionRepository(Protocol):
|
||||
"""Factory for one short transaction owning the complete submission use case."""
|
||||
|
||||
def transaction(self, scope: SubmissionAttemptScope) -> AbstractContextManager[SubmissionTransaction]: ...
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AuthorizedSubmissionCommit",
|
||||
"RetryableSubmissionPersistenceError",
|
||||
"SubmissionAttemptScope",
|
||||
"SubmissionCommitResult",
|
||||
"SubmissionCommitStatus",
|
||||
"SubmissionRepository",
|
||||
"SubmissionTransaction",
|
||||
]
|
||||
@@ -1,102 +0,0 @@
|
||||
"""Immutable submission and shared authorization-audit persistence values.
|
||||
|
||||
These values preserve business identity and structured snapshots without
|
||||
exposing ORM records. Persistence mappers alone translate them to storage
|
||||
columns and Pydantic JSON values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from core.human_input_v2.entities import HumanInputDeliveryChannel
|
||||
from core.human_input_v2.shared import (
|
||||
ApproverGrantId,
|
||||
AuditEventId,
|
||||
DeliveryEndpointId,
|
||||
SubmissionId,
|
||||
UtcTimestamp,
|
||||
)
|
||||
|
||||
from .frozen_values import FrozenJSONObject
|
||||
from .grants import FormRef
|
||||
from .submission_authorization import SubmissionActor, VerifiedEmailOTPProof, VerifiedSubmissionProof
|
||||
|
||||
|
||||
class FormAuthorizationAuditEventType(StrEnum):
|
||||
"""Stable append-only event names owned by the shared audit table."""
|
||||
|
||||
OTP_CHALLENGE_ISSUED = "otp_challenge_issued"
|
||||
SUBMISSION_AUTHORIZED = "submission_authorized"
|
||||
SUBMISSION_REJECTED = "submission_rejected"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FormAuthorizationAuditEvent:
|
||||
"""Secret-free authorized, rejected, or OTP issuance audit fact."""
|
||||
|
||||
id: AuditEventId
|
||||
event_type: FormAuthorizationAuditEventType
|
||||
form_ref: FormRef
|
||||
approver_grant_id: ApproverGrantId | None
|
||||
endpoint_id: DeliveryEndpointId | None
|
||||
channel: HumanInputDeliveryChannel | None
|
||||
reason_code: str | None
|
||||
reason_message: str | None
|
||||
authorization_proof: VerifiedSubmissionProof | None
|
||||
payload: FrozenJSONObject | None
|
||||
occurred_at: UtcTimestamp
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.event_type is FormAuthorizationAuditEventType.SUBMISSION_AUTHORIZED:
|
||||
if self.approver_grant_id is None or self.authorization_proof is None:
|
||||
raise ValueError("authorized audit event requires a grant and verified proof")
|
||||
if self.reason_code is not None:
|
||||
raise ValueError("authorized audit event cannot contain a rejection reason")
|
||||
self.validate_authorization_proof_owner()
|
||||
if self.event_type is FormAuthorizationAuditEventType.SUBMISSION_REJECTED and not self.reason_code:
|
||||
raise ValueError("rejected audit event requires a stable reason code")
|
||||
|
||||
def validate_authorization_proof_owner(self) -> None:
|
||||
"""Reject authorized Email evidence captured for another form or grant."""
|
||||
|
||||
proof = self.authorization_proof
|
||||
if not isinstance(proof, VerifiedEmailOTPProof):
|
||||
return
|
||||
if (
|
||||
proof.challenge_ref.form_ref != self.form_ref
|
||||
or proof.challenge_ref.grant_ref.grant_id != self.approver_grant_id
|
||||
):
|
||||
raise ValueError("authorized Email proof owner does not match the audit event")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FormSubmission:
|
||||
"""Immutable winning submission mapped independently from ORM lifetime."""
|
||||
|
||||
id: SubmissionId
|
||||
form_ref: FormRef
|
||||
approver_grant_id: ApproverGrantId
|
||||
endpoint_id: DeliveryEndpointId | None
|
||||
authorization_audit_event_id: AuditEventId
|
||||
actor: SubmissionActor
|
||||
selected_action_id: str
|
||||
input_snapshot: FrozenJSONObject
|
||||
canonical_values: FrozenJSONObject
|
||||
submitted_at: UtcTimestamp
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.selected_action_id.strip():
|
||||
raise ValueError("submission selected action must not be blank")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FormAuthorizationAuditEvent",
|
||||
"FormAuthorizationAuditEventType",
|
||||
"FormSubmission",
|
||||
]
|
||||
@@ -1,37 +0,0 @@
|
||||
"""Infrastructure-free Contact Directory domain boundary.
|
||||
|
||||
Transport and persistence layers may depend on this package. This package must
|
||||
not import controllers, Flask, SQLAlchemy, sessions, or ORM records.
|
||||
"""
|
||||
|
||||
from .entities import (
|
||||
Contact,
|
||||
ContactIdentitySource,
|
||||
ContactOwner,
|
||||
ContactSnapshot,
|
||||
ExternalContactOwner,
|
||||
OrganizationAccountOwner,
|
||||
PlatformWorkspaceEntry,
|
||||
WorkspaceMemberOwner,
|
||||
)
|
||||
from .errors import ContactDirectoryError, ContactRejection, ContactRejectionCode
|
||||
from .policy import ContactDirectoryPolicy, ContactDirectorySnapshot, ContactResolution
|
||||
from .ports import ContactDirectoryRepository
|
||||
|
||||
__all__ = [
|
||||
"Contact",
|
||||
"ContactDirectoryError",
|
||||
"ContactDirectoryPolicy",
|
||||
"ContactDirectoryRepository",
|
||||
"ContactDirectorySnapshot",
|
||||
"ContactIdentitySource",
|
||||
"ContactOwner",
|
||||
"ContactRejection",
|
||||
"ContactRejectionCode",
|
||||
"ContactResolution",
|
||||
"ContactSnapshot",
|
||||
"ExternalContactOwner",
|
||||
"OrganizationAccountOwner",
|
||||
"PlatformWorkspaceEntry",
|
||||
"WorkspaceMemberOwner",
|
||||
]
|
||||
@@ -1,229 +0,0 @@
|
||||
"""Canonical Contact identity independent of workspace-relative resolution.
|
||||
|
||||
Contacts own identity invariants and current profile facts. Membership,
|
||||
allow-list state, database I/O, and transport serialization remain outside the
|
||||
entity so its immutable lifecycle source cannot be confused with a query result.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
DeploymentScope,
|
||||
DirectoryScope,
|
||||
NormalizedEmail,
|
||||
PlatformEntryId,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
WorkspaceScope,
|
||||
)
|
||||
|
||||
from .errors import ContactRejectionCode, reject
|
||||
|
||||
|
||||
class ContactIdentitySource(StrEnum):
|
||||
"""Immutable lifecycle source of a canonical Contact."""
|
||||
|
||||
ORGANIZATION_ACCOUNT = "organization_account"
|
||||
WORKSPACE_MEMBER = "workspace_member"
|
||||
EXTERNAL = "external"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationAccountOwner:
|
||||
"""Deployment-wide EE owner reference backed by one Account."""
|
||||
|
||||
account_id: AccountId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkspaceMemberOwner:
|
||||
"""Workspace owner reference backed by one current or historical Account."""
|
||||
|
||||
workspace_id: WorkspaceId
|
||||
account_id: AccountId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExternalContactOwner:
|
||||
"""Workspace owner reference for an address managed by administrators."""
|
||||
|
||||
workspace_id: WorkspaceId
|
||||
|
||||
|
||||
type ContactOwner = OrganizationAccountOwner | WorkspaceMemberOwner | ExternalContactOwner
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Contact:
|
||||
"""Canonical identity whose source and owner remain immutable.
|
||||
|
||||
Use the named factories for normal construction. ``create`` exists for
|
||||
persistence mapping and validates the same source/owner invariant.
|
||||
"""
|
||||
|
||||
id: ContactId
|
||||
identity_source: ContactIdentitySource
|
||||
owner: ContactOwner
|
||||
name: str
|
||||
normalized_name: str
|
||||
email: str | None
|
||||
normalized_email: NormalizedEmail | None
|
||||
avatar_file_id: str | None
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
expected_owner_type = {
|
||||
ContactIdentitySource.ORGANIZATION_ACCOUNT: OrganizationAccountOwner,
|
||||
ContactIdentitySource.WORKSPACE_MEMBER: WorkspaceMemberOwner,
|
||||
ContactIdentitySource.EXTERNAL: ExternalContactOwner,
|
||||
}[self.identity_source]
|
||||
if not isinstance(self.owner, expected_owner_type):
|
||||
raise reject(ContactRejectionCode.INVALID_OWNER)
|
||||
if not self.name.strip():
|
||||
raise reject(ContactRejectionCode.INVALID_NAME)
|
||||
if self.identity_source is ContactIdentitySource.EXTERNAL and self.normalized_email is None:
|
||||
raise reject(ContactRejectionCode.INVALID_EMAIL)
|
||||
if self.email is None and self.normalized_email is not None:
|
||||
raise reject(ContactRejectionCode.INVALID_EMAIL)
|
||||
if self.email is not None:
|
||||
try:
|
||||
normalized_email = NormalizedEmail(self.email)
|
||||
except ValueError as error:
|
||||
raise reject(ContactRejectionCode.INVALID_EMAIL) from error
|
||||
if self.normalized_email != normalized_email:
|
||||
raise reject(ContactRejectionCode.INVALID_EMAIL)
|
||||
object.__setattr__(self, "email", self.email.strip())
|
||||
normalized_name = self.name.strip().casefold()
|
||||
if self.normalized_name != normalized_name:
|
||||
object.__setattr__(self, "normalized_name", normalized_name)
|
||||
object.__setattr__(self, "name", self.name.strip())
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
contact_id: ContactId,
|
||||
identity_source: ContactIdentitySource,
|
||||
owner: ContactOwner,
|
||||
name: str,
|
||||
email: str | None,
|
||||
now: UtcTimestamp,
|
||||
avatar_file_id: str | None = None,
|
||||
created_at: UtcTimestamp | None = None,
|
||||
) -> Contact:
|
||||
normalized_email: NormalizedEmail | None = None
|
||||
if email is not None:
|
||||
try:
|
||||
normalized_email = NormalizedEmail(email)
|
||||
except ValueError as error:
|
||||
raise reject(ContactRejectionCode.INVALID_EMAIL) from error
|
||||
return cls(
|
||||
id=contact_id,
|
||||
identity_source=identity_source,
|
||||
owner=owner,
|
||||
name=name,
|
||||
normalized_name=name.strip().casefold(),
|
||||
email=email,
|
||||
normalized_email=normalized_email,
|
||||
avatar_file_id=avatar_file_id,
|
||||
created_at=created_at or now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def organization_account(
|
||||
cls,
|
||||
*,
|
||||
contact_id: ContactId,
|
||||
account_id: AccountId,
|
||||
name: str,
|
||||
email: str | None,
|
||||
now: UtcTimestamp,
|
||||
) -> Contact:
|
||||
return cls.create(
|
||||
contact_id=contact_id,
|
||||
identity_source=ContactIdentitySource.ORGANIZATION_ACCOUNT,
|
||||
owner=OrganizationAccountOwner(account_id),
|
||||
name=name,
|
||||
email=email,
|
||||
now=now,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def workspace_member(
|
||||
cls,
|
||||
*,
|
||||
contact_id: ContactId,
|
||||
workspace_id: WorkspaceId,
|
||||
account_id: AccountId,
|
||||
name: str,
|
||||
email: str | None,
|
||||
now: UtcTimestamp,
|
||||
) -> Contact:
|
||||
return cls.create(
|
||||
contact_id=contact_id,
|
||||
identity_source=ContactIdentitySource.WORKSPACE_MEMBER,
|
||||
owner=WorkspaceMemberOwner(workspace_id, account_id),
|
||||
name=name,
|
||||
email=email,
|
||||
now=now,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def external(
|
||||
cls,
|
||||
*,
|
||||
contact_id: ContactId,
|
||||
workspace_id: WorkspaceId,
|
||||
name: str,
|
||||
email: str,
|
||||
now: UtcTimestamp,
|
||||
avatar_file_id: str | None = None,
|
||||
) -> Contact:
|
||||
return cls.create(
|
||||
contact_id=contact_id,
|
||||
identity_source=ContactIdentitySource.EXTERNAL,
|
||||
owner=ExternalContactOwner(workspace_id),
|
||||
name=name,
|
||||
email=email,
|
||||
now=now,
|
||||
avatar_file_id=avatar_file_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def account_id(self) -> AccountId | None:
|
||||
if isinstance(self.owner, OrganizationAccountOwner | WorkspaceMemberOwner):
|
||||
return self.owner.account_id
|
||||
return None
|
||||
|
||||
@property
|
||||
def directory_scope(self) -> DirectoryScope:
|
||||
if isinstance(self.owner, OrganizationAccountOwner):
|
||||
return DeploymentScope()
|
||||
return WorkspaceScope(self.owner.workspace_id)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactSnapshot:
|
||||
"""Immutable Contact plus current Account availability for one operation."""
|
||||
|
||||
contact: Contact
|
||||
account_available: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PlatformWorkspaceEntry:
|
||||
"""One workspace allow-list fact for an Organization Account Contact."""
|
||||
|
||||
id: PlatformEntryId
|
||||
workspace_id: WorkspaceId
|
||||
contact_id: ContactId
|
||||
added_by_account_id: AccountId
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
@@ -1,48 +0,0 @@
|
||||
"""Transport-neutral Contact Directory rejection contracts."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class ContactRejectionCode(StrEnum):
|
||||
"""Stable machine-readable reasons returned by Contact Directory operations."""
|
||||
|
||||
INVALID_OWNER = "invalid_owner"
|
||||
INVALID_EMAIL = "invalid_email"
|
||||
INVALID_NAME = "invalid_name"
|
||||
CONFLICTING_IDENTITY = "conflicting_identity"
|
||||
CROSS_ORGANIZATION = "cross_organization"
|
||||
ACCOUNT_UNAVAILABLE = "account_unavailable"
|
||||
CONTACT_NOT_FOUND = "contact_not_found"
|
||||
SETUP_ROW_MISSING = "setup_row_missing"
|
||||
PERSISTENCE_FAILURE = "persistence_failure"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactRejection:
|
||||
"""Serializable domain rejection without HTTP or RPC semantics."""
|
||||
|
||||
reason: ContactRejectionCode
|
||||
|
||||
def to_primitive(self) -> dict[str, str]:
|
||||
return {"reason": self.reason.value}
|
||||
|
||||
|
||||
class ContactDirectoryError(Exception):
|
||||
"""Exception carrier for one transport-neutral Contact rejection."""
|
||||
|
||||
rejection: ContactRejection
|
||||
|
||||
def __init__(self, rejection: ContactRejection) -> None:
|
||||
self.rejection = rejection
|
||||
super().__init__(rejection.reason.value)
|
||||
|
||||
@property
|
||||
def code(self) -> ContactRejectionCode:
|
||||
return self.rejection.reason
|
||||
|
||||
|
||||
def reject(reason: ContactRejectionCode) -> ContactDirectoryError:
|
||||
"""Build a domain exception while keeping reason construction consistent."""
|
||||
|
||||
return ContactDirectoryError(ContactRejection(reason))
|
||||
@@ -1,101 +0,0 @@
|
||||
"""Pure Contact Directory resolution and lifecycle policies.
|
||||
|
||||
The immutable snapshot supplies all operation-scoped facts. Policies never load
|
||||
membership, Account, allow-list, or Contact records themselves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from core.human_input_v2.shared import AccountId, ContactId, NormalizedEmail, UtcTimestamp, WorkspaceId
|
||||
|
||||
from .entities import Contact, ExternalContactOwner, OrganizationAccountOwner, WorkspaceMemberOwner
|
||||
from .errors import ContactRejectionCode, reject
|
||||
|
||||
|
||||
class ContactResolution(StrEnum):
|
||||
"""Workspace-relative availability of one canonical Contact."""
|
||||
|
||||
WORKSPACE = "workspace"
|
||||
PLATFORM = "platform"
|
||||
EXTERNAL = "external"
|
||||
ABSENT = "absent"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactDirectorySnapshot:
|
||||
"""Coherent, request-scoped Contact facts for one workspace.
|
||||
|
||||
The snapshot is deliberately not a cache. Authorization callers that need
|
||||
current facts must load a new snapshot in their own operation.
|
||||
"""
|
||||
|
||||
workspace_id: WorkspaceId
|
||||
contacts: tuple[Contact, ...] = ()
|
||||
member_account_ids: frozenset[AccountId] = frozenset()
|
||||
platform_contact_ids: frozenset[ContactId] = frozenset()
|
||||
unavailable_account_ids: frozenset[AccountId] = frozenset()
|
||||
|
||||
def find(self, contact_id: ContactId) -> Contact | None:
|
||||
return next((contact for contact in self.contacts if contact.id == contact_id), None)
|
||||
|
||||
|
||||
class ContactDirectoryPolicy:
|
||||
"""Stateless policy for workspace resolution and External lifecycle rules."""
|
||||
|
||||
@staticmethod
|
||||
def resolve_for_workspace(snapshot: ContactDirectorySnapshot, contact_id: ContactId) -> ContactResolution:
|
||||
contact = snapshot.find(contact_id)
|
||||
if contact is None:
|
||||
return ContactResolution.ABSENT
|
||||
|
||||
owner = contact.owner
|
||||
if isinstance(owner, ExternalContactOwner):
|
||||
if owner.workspace_id != snapshot.workspace_id:
|
||||
raise reject(ContactRejectionCode.CROSS_ORGANIZATION)
|
||||
return ContactResolution.EXTERNAL
|
||||
if isinstance(owner, WorkspaceMemberOwner) and owner.workspace_id != snapshot.workspace_id:
|
||||
raise reject(ContactRejectionCode.CROSS_ORGANIZATION)
|
||||
|
||||
account_id = contact.account_id
|
||||
if account_id is None or account_id in snapshot.unavailable_account_ids:
|
||||
return ContactResolution.ABSENT
|
||||
if account_id in snapshot.member_account_ids:
|
||||
return ContactResolution.WORKSPACE
|
||||
if isinstance(owner, OrganizationAccountOwner) and contact.id in snapshot.platform_contact_ids:
|
||||
return ContactResolution.PLATFORM
|
||||
return ContactResolution.ABSENT
|
||||
|
||||
@staticmethod
|
||||
def admit_external(
|
||||
snapshot: ContactDirectorySnapshot,
|
||||
*,
|
||||
contact_id: ContactId,
|
||||
name: str,
|
||||
email: str,
|
||||
now: UtcTimestamp,
|
||||
avatar_file_id: str | None = None,
|
||||
) -> Contact:
|
||||
try:
|
||||
normalized_email = NormalizedEmail(email)
|
||||
except ValueError as error:
|
||||
raise reject(ContactRejectionCode.INVALID_EMAIL) from error
|
||||
if any(contact.normalized_email == normalized_email for contact in snapshot.contacts):
|
||||
raise reject(ContactRejectionCode.CONFLICTING_IDENTITY)
|
||||
return Contact.external(
|
||||
contact_id=contact_id,
|
||||
workspace_id=snapshot.workspace_id,
|
||||
name=name,
|
||||
email=email,
|
||||
now=now,
|
||||
avatar_file_id=avatar_file_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def ensure_external_deletable(contact: Contact, workspace_id: WorkspaceId) -> None:
|
||||
if not isinstance(contact.owner, ExternalContactOwner):
|
||||
raise reject(ContactRejectionCode.INVALID_OWNER)
|
||||
if contact.owner.workspace_id != workspace_id:
|
||||
raise reject(ContactRejectionCode.CROSS_ORGANIZATION)
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Aggregate-oriented persistence ports for Contact Directory operations.
|
||||
|
||||
Implementations own transactions, owner predicates, locking, mapping, and
|
||||
rollback. Callers receive domain values and never persistence records.
|
||||
"""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from core.human_input_v2.shared import AccountId, ContactId, WorkspaceId
|
||||
|
||||
from .entities import Contact
|
||||
from .policy import ContactDirectorySnapshot
|
||||
|
||||
|
||||
class ContactDirectoryRepository(Protocol):
|
||||
"""Persistence contract centered on coherent directory invariants."""
|
||||
|
||||
def load_snapshot(self, workspace_id: WorkspaceId) -> ContactDirectorySnapshot:
|
||||
"""Load one immutable workspace-scoped directory view."""
|
||||
...
|
||||
|
||||
def save_organization_contact(self, contact: Contact) -> Contact:
|
||||
"""Create or update one deployment-owned Organization Contact with a serialized Email claim."""
|
||||
...
|
||||
|
||||
def save_workspace_member_contact(self, contact: Contact) -> Contact:
|
||||
"""Create or update one Contact backed by current workspace membership."""
|
||||
...
|
||||
|
||||
def admit_external(self, workspace_id: WorkspaceId, *, name: str, email: str) -> Contact:
|
||||
"""Atomically admit one External Contact against tenant and configured Organization identities."""
|
||||
...
|
||||
|
||||
def set_platform_availability(
|
||||
self,
|
||||
workspace_id: WorkspaceId,
|
||||
contact_id: ContactId,
|
||||
*,
|
||||
added_by_account_id: AccountId,
|
||||
enabled: bool,
|
||||
) -> None:
|
||||
"""Atomically add or remove one EE Platform allow-list fact."""
|
||||
...
|
||||
|
||||
def hard_delete_external(self, workspace_id: WorkspaceId, contact_id: ContactId) -> None:
|
||||
"""Delete an External Contact without retaining an identity tombstone."""
|
||||
...
|
||||
@@ -1,197 +0,0 @@
|
||||
from enum import StrEnum
|
||||
from typing import NewType
|
||||
|
||||
from core.human_input_v2.shared import AccountId, NormalizedEmail, UtcTimestamp, WorkspaceId
|
||||
|
||||
|
||||
class HumanInputContactType(StrEnum):
|
||||
"""Concrete contact classification resolved in one workspace."""
|
||||
|
||||
WORKSPACE = "workspace"
|
||||
PLATFORM = "platform"
|
||||
EXTERNAL = "external"
|
||||
|
||||
|
||||
class HumanInputApproverGrantSubjectType(StrEnum):
|
||||
"""Business subject receiving approval authority for one Human Input form."""
|
||||
|
||||
CONTACT = "contact"
|
||||
END_USER = "end_user"
|
||||
EMAIL_ADDRESS = "email_address"
|
||||
|
||||
|
||||
class HumanInputSubmissionActorType(StrEnum):
|
||||
"""Business identity that completed one Human Input form submission."""
|
||||
|
||||
ACCOUNT = "account"
|
||||
END_USER = "end_user"
|
||||
EMAIL_ADDRESS = "email_address"
|
||||
|
||||
|
||||
class HumanInputV2FormKind(StrEnum):
|
||||
"""Persistence kind for an independently stored Human Input v2 form."""
|
||||
|
||||
RUNTIME = "runtime"
|
||||
DELIVERY_TEST = "delivery_test"
|
||||
|
||||
|
||||
class HumanInputV2FormStatus(StrEnum):
|
||||
"""Lifecycle state of an independently stored Human Input v2 form."""
|
||||
|
||||
WAITING = "waiting"
|
||||
EXPIRED = "expired"
|
||||
SUBMITTED = "submitted"
|
||||
TIMEOUT = "timeout"
|
||||
|
||||
|
||||
class HumanInputAuthorizationProofType(StrEnum):
|
||||
"""Verified evidence type retained for a Human Input authorization audit event."""
|
||||
|
||||
ACCOUNT_SESSION = "account_session"
|
||||
EMAIL_OTP = "email_otp"
|
||||
IM_IDENTITY = "im_identity"
|
||||
TRUSTED_END_USER = "trusted_end_user"
|
||||
|
||||
|
||||
class HumanInputDeliveryChannel(StrEnum):
|
||||
"""Notification or interaction channel frozen for one form endpoint."""
|
||||
|
||||
EMAIL = "email"
|
||||
IM = "im"
|
||||
WEB = "web"
|
||||
CONSOLE = "console"
|
||||
|
||||
|
||||
class HumanInputDeliveryAttemptStatus(StrEnum):
|
||||
"""Delivery lifecycle kept separate from the form state machine."""
|
||||
|
||||
QUEUED = "queued"
|
||||
SENDING = "sending"
|
||||
SENT = "sent"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class HumanInputOTPChallengeStatus(StrEnum):
|
||||
"""Current usability of an email proof challenge."""
|
||||
|
||||
PENDING = "pending"
|
||||
VERIFIED = "verified"
|
||||
INVALIDATED = "invalidated"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
class IMProvider(StrEnum):
|
||||
"""IM provider supported by Human Input contact and delivery flows."""
|
||||
|
||||
FEISHU = "feishu"
|
||||
SLACK = "slack"
|
||||
DING_TALK = "ding_talk"
|
||||
MS_TEAMS = "ms_teams"
|
||||
WE_COM = "we_com"
|
||||
LARK = "lark"
|
||||
|
||||
|
||||
class IMBindingScope(StrEnum):
|
||||
"""Resolution scope of a contact-to-IM-identity binding."""
|
||||
|
||||
WORKSPACE = "workspace"
|
||||
ORGANIZATION = "organization"
|
||||
|
||||
|
||||
class IMIntegrationStatus(StrEnum):
|
||||
"""Connectivity state of an organization-level IM integration."""
|
||||
|
||||
NOT_CONFIGURED = "not_configured"
|
||||
CONFIGURED = "configured"
|
||||
CONNECTED = "connected"
|
||||
PERMISSION_ISSUE = "permission_issue"
|
||||
CALLBACK_ERROR = "callback_error"
|
||||
CONNECTION_ERROR = "connection_error"
|
||||
|
||||
|
||||
class IMIdentityBindingStatus(StrEnum):
|
||||
"""Whether a synchronized IM identity is currently bound."""
|
||||
|
||||
UNBOUND = "unbound"
|
||||
BOUND = "bound"
|
||||
|
||||
|
||||
class IMSyncRunStatus(StrEnum):
|
||||
"""Lifecycle state of a manual IM directory synchronization."""
|
||||
|
||||
QUEUED = "queued"
|
||||
RUNNING = "running"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class IMSyncResultType(StrEnum):
|
||||
"""Stable reconciliation bucket for one synchronized directory entry."""
|
||||
|
||||
ADDED = "added"
|
||||
NOT_MATCHED = "not_matched"
|
||||
FAILED = "failed"
|
||||
REMOVED = "removed"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class IMSyncRemovalReason(StrEnum):
|
||||
"""Stable reason for removing or replacing a current IM binding."""
|
||||
|
||||
NOT_PRESENT_IN_DIRECTORY = "not_present_in_directory"
|
||||
BINDING_INVALIDATED = "binding_invalidated"
|
||||
BINDING_REPLACED = "binding_replaced"
|
||||
|
||||
|
||||
class EmailProviderType(StrEnum):
|
||||
"""Email provider supported by organization-level Human Input delivery."""
|
||||
|
||||
RESEND = "resend"
|
||||
|
||||
|
||||
# Identifiers for organization candidates and contacts.
|
||||
OrganizationCandidateId = NewType("OrganizationCandidateId", str)
|
||||
|
||||
# Legacy transport identifier. Contact Directory code uses the richer value
|
||||
# object from ``core.human_input_v2.shared`` at its domain boundary.
|
||||
ContactId = NewType("ContactId", str)
|
||||
|
||||
# Identifiers for synced IM identiies. This is not the same as user_id or account_id
|
||||
# on the IM provier side. It is the identifier for the synced IM user record in Dify.
|
||||
IMIdentityId = NewType("IMIdentityId", str)
|
||||
|
||||
# Identifiers for a full IM user synchorization.
|
||||
IMSyncRunId = NewType("IMSyncRunId", str)
|
||||
|
||||
# Identifier for an IM binding, an association between an IM identity and a Dify contact.
|
||||
IMBindingId = NewType("IMBindingId", str)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AccountId",
|
||||
"ContactId",
|
||||
"EmailProviderType",
|
||||
"HumanInputApproverGrantSubjectType",
|
||||
"HumanInputAuthorizationProofType",
|
||||
"HumanInputContactType",
|
||||
"HumanInputDeliveryAttemptStatus",
|
||||
"HumanInputDeliveryChannel",
|
||||
"HumanInputOTPChallengeStatus",
|
||||
"HumanInputSubmissionActorType",
|
||||
"HumanInputV2FormKind",
|
||||
"HumanInputV2FormStatus",
|
||||
"IMBindingId",
|
||||
"IMBindingScope",
|
||||
"IMIdentityBindingStatus",
|
||||
"IMIdentityId",
|
||||
"IMIntegrationStatus",
|
||||
"IMProvider",
|
||||
"IMSyncRemovalReason",
|
||||
"IMSyncResultType",
|
||||
"IMSyncRunId",
|
||||
"IMSyncRunStatus",
|
||||
"NormalizedEmail",
|
||||
"OrganizationCandidateId",
|
||||
"UtcTimestamp",
|
||||
"WorkspaceId",
|
||||
]
|
||||
@@ -1,82 +0,0 @@
|
||||
"""Infrastructure-free IM configuration, synchronization, and binding boundary.
|
||||
|
||||
This package depends on canonical Contact Directory facts and shared primitive
|
||||
values. Provider clients, controllers, SQLAlchemy sessions, ORM records, and
|
||||
provider transport payload types belong outside this boundary. The public API
|
||||
hides configuration CAS, sync matching, and effective-binding priority behind
|
||||
domain decisions and transaction-oriented ports.
|
||||
"""
|
||||
|
||||
from .binding_resolution import (
|
||||
BindingResolutionKind,
|
||||
BindingResolutionResult,
|
||||
EffectiveBindingResolver,
|
||||
EffectiveIMBindingSnapshot,
|
||||
)
|
||||
from .integration import (
|
||||
ConfigurationTransition,
|
||||
ConfigurationTransitionKind,
|
||||
CurrentStateInvalidation,
|
||||
EncryptedCredentials,
|
||||
IMIntegration,
|
||||
IntegrationDeletion,
|
||||
IntegrationRevisionToken,
|
||||
ProviderTenantIdentity,
|
||||
StaleRevision,
|
||||
)
|
||||
from .ports import (
|
||||
ActiveRunDecision,
|
||||
ActiveRunDecisionKind,
|
||||
ApplyReconciliationResult,
|
||||
ApplyReconciliationStatus,
|
||||
IMControlPlaneRepository,
|
||||
)
|
||||
from .records import IMBinding, IMIdentity, OpaqueProviderPayload
|
||||
from .state import IMIntegrationState
|
||||
from .sync_reconciliation import (
|
||||
IMSyncRun,
|
||||
MatchKind,
|
||||
ProviderDirectoryEntry,
|
||||
ReconciliationAction,
|
||||
ReconciliationPlan,
|
||||
ReconciliationSnapshot,
|
||||
SyncContactSnapshot,
|
||||
SyncIdentitySnapshot,
|
||||
SyncReconciler,
|
||||
SyncResultFact,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ActiveRunDecision",
|
||||
"ActiveRunDecisionKind",
|
||||
"ApplyReconciliationResult",
|
||||
"ApplyReconciliationStatus",
|
||||
"BindingResolutionKind",
|
||||
"BindingResolutionResult",
|
||||
"ConfigurationTransition",
|
||||
"ConfigurationTransitionKind",
|
||||
"CurrentStateInvalidation",
|
||||
"EffectiveBindingResolver",
|
||||
"EffectiveIMBindingSnapshot",
|
||||
"EncryptedCredentials",
|
||||
"IMBinding",
|
||||
"IMControlPlaneRepository",
|
||||
"IMIdentity",
|
||||
"IMIntegration",
|
||||
"IMIntegrationState",
|
||||
"IMSyncRun",
|
||||
"IntegrationDeletion",
|
||||
"IntegrationRevisionToken",
|
||||
"MatchKind",
|
||||
"OpaqueProviderPayload",
|
||||
"ProviderDirectoryEntry",
|
||||
"ProviderTenantIdentity",
|
||||
"ReconciliationAction",
|
||||
"ReconciliationPlan",
|
||||
"ReconciliationSnapshot",
|
||||
"StaleRevision",
|
||||
"SyncContactSnapshot",
|
||||
"SyncIdentitySnapshot",
|
||||
"SyncReconciler",
|
||||
"SyncResultFact",
|
||||
]
|
||||
@@ -1,178 +0,0 @@
|
||||
"""Effective binding priority and credential-free consumer snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from core.human_input_v2.contact_directory import ContactSnapshot
|
||||
from core.human_input_v2.entities import IMBindingScope, IMProvider
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IntegrationId,
|
||||
WorkspaceId,
|
||||
)
|
||||
|
||||
from .integration import IntegrationRevisionToken, ProviderTenantIdentity
|
||||
from .records import IMBinding, IMIdentity
|
||||
|
||||
|
||||
class BindingResolutionKind(StrEnum):
|
||||
"""Stable priority result returned to control-plane consumers."""
|
||||
|
||||
WORKSPACE_OVERRIDE = "workspace_override"
|
||||
ORGANIZATION_BINDING = "organization_binding"
|
||||
EMAIL_FALLBACK = "email_fallback"
|
||||
NOT_AVAILABLE = "not_available"
|
||||
INVALID_BINDING = "invalid_binding"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EffectiveIMBindingSnapshot:
|
||||
"""Consumer-safe effective channel facts without credentials or raw payloads."""
|
||||
|
||||
integration_id: IntegrationId
|
||||
integration_config_version: int
|
||||
provider: IMProvider
|
||||
provider_tenant_id: str
|
||||
contact_id: ContactId
|
||||
account_id: AccountId | None
|
||||
identity_id: IMIdentityId
|
||||
binding_id: IMBindingId | None
|
||||
provider_user_id: str
|
||||
display_name: str | None
|
||||
email: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BindingResolutionResult:
|
||||
"""Effective binding or stable rejection without leaking invalid records."""
|
||||
|
||||
kind: BindingResolutionKind
|
||||
binding: EffectiveIMBindingSnapshot | None
|
||||
|
||||
|
||||
class EffectiveBindingResolver:
|
||||
"""Resolve workspace override, organization binding, then Email fallback."""
|
||||
|
||||
@staticmethod
|
||||
def resolve(
|
||||
*,
|
||||
integration_revision: IntegrationRevisionToken,
|
||||
provider_tenant: ProviderTenantIdentity,
|
||||
workspace_id: WorkspaceId,
|
||||
contact: ContactSnapshot,
|
||||
identities: tuple[IMIdentity, ...],
|
||||
bindings: tuple[IMBinding, ...],
|
||||
) -> BindingResolutionResult:
|
||||
identities_by_id = {identity.id: identity for identity in identities}
|
||||
candidates = [binding for binding in bindings if binding.contact_id == contact.contact.id]
|
||||
workspace_binding = next(
|
||||
(
|
||||
binding
|
||||
for binding in candidates
|
||||
if binding.scope is IMBindingScope.WORKSPACE and binding.scope_id == str(workspace_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
organization_binding = next(
|
||||
(
|
||||
binding
|
||||
for binding in candidates
|
||||
if binding.scope is IMBindingScope.ORGANIZATION
|
||||
and binding.scope_id == str(integration_revision.integration_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
selected = workspace_binding or organization_binding
|
||||
if selected is not None:
|
||||
identity = identities_by_id.get(selected.identity_id)
|
||||
if not EffectiveBindingResolver._matches_integration(
|
||||
selected,
|
||||
identity,
|
||||
integration_revision,
|
||||
provider_tenant,
|
||||
):
|
||||
return BindingResolutionResult(BindingResolutionKind.INVALID_BINDING, None)
|
||||
assert identity is not None
|
||||
kind = (
|
||||
BindingResolutionKind.WORKSPACE_OVERRIDE
|
||||
if selected is workspace_binding
|
||||
else BindingResolutionKind.ORGANIZATION_BINDING
|
||||
)
|
||||
return BindingResolutionResult(
|
||||
kind,
|
||||
EffectiveBindingResolver._snapshot(
|
||||
integration_revision,
|
||||
provider_tenant,
|
||||
contact,
|
||||
identity,
|
||||
selected.id,
|
||||
),
|
||||
)
|
||||
|
||||
normalized_email = contact.contact.normalized_email
|
||||
if normalized_email is not None:
|
||||
identity = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in identities
|
||||
if candidate.integration_id == integration_revision.integration_id
|
||||
and candidate.provider is provider_tenant.provider
|
||||
and candidate.normalized_email == normalized_email
|
||||
),
|
||||
None,
|
||||
)
|
||||
if identity is not None:
|
||||
return BindingResolutionResult(
|
||||
BindingResolutionKind.EMAIL_FALLBACK,
|
||||
EffectiveBindingResolver._snapshot(
|
||||
integration_revision,
|
||||
provider_tenant,
|
||||
contact,
|
||||
identity,
|
||||
None,
|
||||
),
|
||||
)
|
||||
return BindingResolutionResult(BindingResolutionKind.NOT_AVAILABLE, None)
|
||||
|
||||
@staticmethod
|
||||
def _matches_integration(
|
||||
binding: IMBinding,
|
||||
identity: IMIdentity | None,
|
||||
integration_revision: IntegrationRevisionToken,
|
||||
provider_tenant: ProviderTenantIdentity,
|
||||
) -> bool:
|
||||
return (
|
||||
identity is not None
|
||||
and binding.integration_id == integration_revision.integration_id
|
||||
and identity.integration_id == integration_revision.integration_id
|
||||
and binding.provider is provider_tenant.provider
|
||||
and identity.provider is provider_tenant.provider
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(
|
||||
revision: IntegrationRevisionToken,
|
||||
provider_tenant: ProviderTenantIdentity,
|
||||
contact: ContactSnapshot,
|
||||
identity: IMIdentity,
|
||||
binding_id: IMBindingId | None,
|
||||
) -> EffectiveIMBindingSnapshot:
|
||||
return EffectiveIMBindingSnapshot(
|
||||
integration_id=revision.integration_id,
|
||||
integration_config_version=revision.config_version,
|
||||
provider=provider_tenant.provider,
|
||||
provider_tenant_id=provider_tenant.provider_tenant_id,
|
||||
contact_id=contact.contact.id,
|
||||
account_id=contact.contact.account_id,
|
||||
identity_id=identity.id,
|
||||
binding_id=binding_id,
|
||||
provider_user_id=identity.provider_user_id,
|
||||
display_name=identity.display_name,
|
||||
email=identity.email,
|
||||
)
|
||||
@@ -1,236 +0,0 @@
|
||||
"""IM Integration aggregate and complete compare-and-swap revision values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, replace
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import JsonValue
|
||||
|
||||
from core.human_input_v2.entities import IMIntegrationStatus, IMProvider
|
||||
from core.human_input_v2.shared import AccountId, IntegrationId, UtcTimestamp, WorkspaceId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EncryptedCredentials:
|
||||
"""Immutable opaque encrypted configuration passed through the domain boundary."""
|
||||
|
||||
_serialized: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, values: Mapping[str, JsonValue]) -> EncryptedCredentials:
|
||||
if not values:
|
||||
raise ValueError("encrypted credentials must not be empty")
|
||||
return cls(json.dumps(dict(values), sort_keys=True, separators=(",", ":")))
|
||||
|
||||
def to_mapping(self) -> dict[str, JsonValue]:
|
||||
value = json.loads(self._serialized)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("encrypted credentials must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProviderTenantIdentity:
|
||||
"""Provider plus its confirmed organization or workspace identity."""
|
||||
|
||||
provider: IMProvider
|
||||
provider_tenant_id: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.provider_tenant_id.strip():
|
||||
raise ValueError("provider tenant id must not be blank")
|
||||
object.__setattr__(self, "provider_tenant_id", self.provider_tenant_id.strip())
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IntegrationRevisionToken:
|
||||
"""Complete CAS token that prevents identity-replacement ABA."""
|
||||
|
||||
integration_id: IntegrationId
|
||||
config_version: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.config_version < 1:
|
||||
raise ValueError("config version must be positive")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StaleRevision:
|
||||
"""Stable rejection for a token that no longer names current configuration."""
|
||||
|
||||
expected: IntegrationRevisionToken
|
||||
actual: IntegrationRevisionToken | None
|
||||
|
||||
|
||||
class ConfigurationTransitionKind(StrEnum):
|
||||
"""Current-state effect selected by one confirmed configuration write."""
|
||||
|
||||
CREDENTIAL_ROTATION = "credential_rotation"
|
||||
PROVIDER_REPLACEMENT = "provider_replacement"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CurrentStateInvalidation:
|
||||
"""Current identity and binding cleanup owned by the configuration transaction."""
|
||||
|
||||
invalidate_identities: bool
|
||||
invalidate_bindings: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationTransition:
|
||||
"""Atomic configuration write plus its current-state cleanup decision."""
|
||||
|
||||
expected_revision: IntegrationRevisionToken
|
||||
kind: ConfigurationTransitionKind
|
||||
integration: IMIntegration
|
||||
invalidation: CurrentStateInvalidation
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IntegrationDeletion:
|
||||
"""CAS-authorized deletion and current-state invalidation decision."""
|
||||
|
||||
expected_revision: IntegrationRevisionToken
|
||||
invalidation: CurrentStateInvalidation = CurrentStateInvalidation(True, True)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMIntegration:
|
||||
"""Organization IM configuration aggregate.
|
||||
|
||||
Provider reads and credential encryption happen before construction.
|
||||
Configuration transitions return decisions; persistence adapters alone make
|
||||
them atomic and decide whether the expected revision is still current.
|
||||
Connectivity diagnostics are non-configuration state and retain the token.
|
||||
"""
|
||||
|
||||
id: IntegrationId
|
||||
workspace_id: WorkspaceId | None
|
||||
provider_tenant: ProviderTenantIdentity
|
||||
encrypted_credentials: EncryptedCredentials
|
||||
configured_by_account_id: AccountId | None
|
||||
callback_url: str | None
|
||||
config_version: int
|
||||
status: IMIntegrationStatus
|
||||
safe_status_reason: str | None
|
||||
last_checked_at: UtcTimestamp | None
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.config_version < 1:
|
||||
raise ValueError("config version must be positive")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
integration_id: IntegrationId,
|
||||
workspace_id: WorkspaceId | None,
|
||||
provider_tenant: ProviderTenantIdentity,
|
||||
encrypted_credentials: EncryptedCredentials,
|
||||
configured_by_account_id: AccountId | None,
|
||||
callback_url: str | None,
|
||||
now: UtcTimestamp,
|
||||
) -> IMIntegration:
|
||||
return cls(
|
||||
id=integration_id,
|
||||
workspace_id=workspace_id,
|
||||
provider_tenant=provider_tenant,
|
||||
encrypted_credentials=encrypted_credentials,
|
||||
configured_by_account_id=configured_by_account_id,
|
||||
callback_url=callback_url,
|
||||
config_version=1,
|
||||
status=IMIntegrationStatus.CONFIGURED,
|
||||
safe_status_reason=None,
|
||||
last_checked_at=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
@property
|
||||
def revision(self) -> IntegrationRevisionToken:
|
||||
return IntegrationRevisionToken(self.id, self.config_version)
|
||||
|
||||
def reconfigure(
|
||||
self,
|
||||
*,
|
||||
expected_revision: IntegrationRevisionToken,
|
||||
provider_tenant: ProviderTenantIdentity,
|
||||
encrypted_credentials: EncryptedCredentials,
|
||||
configured_by_account_id: AccountId | None,
|
||||
callback_url: str | None,
|
||||
now: UtcTimestamp,
|
||||
replacement_integration_id: IntegrationId | None = None,
|
||||
) -> ConfigurationTransition | StaleRevision:
|
||||
"""Plan a confirmed rotation or replacement without performing I/O."""
|
||||
|
||||
if expected_revision != self.revision:
|
||||
return StaleRevision(expected_revision, self.revision)
|
||||
|
||||
if provider_tenant == self.provider_tenant:
|
||||
if replacement_integration_id not in (None, self.id):
|
||||
raise ValueError("credential rotation must preserve integration identity")
|
||||
updated = replace(
|
||||
self,
|
||||
encrypted_credentials=encrypted_credentials,
|
||||
configured_by_account_id=configured_by_account_id,
|
||||
callback_url=callback_url,
|
||||
config_version=self.config_version + 1,
|
||||
status=IMIntegrationStatus.CONFIGURED,
|
||||
safe_status_reason=None,
|
||||
last_checked_at=None,
|
||||
updated_at=now,
|
||||
)
|
||||
return ConfigurationTransition(
|
||||
expected_revision=expected_revision,
|
||||
kind=ConfigurationTransitionKind.CREDENTIAL_ROTATION,
|
||||
integration=updated,
|
||||
invalidation=CurrentStateInvalidation(False, False),
|
||||
)
|
||||
|
||||
if replacement_integration_id is None or replacement_integration_id == self.id:
|
||||
raise ValueError("provider replacement requires a new integration identity")
|
||||
replacement = IMIntegration.create(
|
||||
integration_id=replacement_integration_id,
|
||||
workspace_id=self.workspace_id,
|
||||
provider_tenant=provider_tenant,
|
||||
encrypted_credentials=encrypted_credentials,
|
||||
configured_by_account_id=configured_by_account_id,
|
||||
callback_url=callback_url,
|
||||
now=now,
|
||||
)
|
||||
return ConfigurationTransition(
|
||||
expected_revision=expected_revision,
|
||||
kind=ConfigurationTransitionKind.PROVIDER_REPLACEMENT,
|
||||
integration=replacement,
|
||||
invalidation=CurrentStateInvalidation(True, True),
|
||||
)
|
||||
|
||||
def plan_deletion(self, expected_revision: IntegrationRevisionToken) -> IntegrationDeletion | StaleRevision:
|
||||
"""Return deletion cleanup only when the complete token is current."""
|
||||
|
||||
if expected_revision != self.revision:
|
||||
return StaleRevision(expected_revision, self.revision)
|
||||
return IntegrationDeletion(expected_revision)
|
||||
|
||||
def record_diagnostics(
|
||||
self,
|
||||
*,
|
||||
status: IMIntegrationStatus,
|
||||
safe_status_reason: str | None,
|
||||
checked_at: UtcTimestamp,
|
||||
) -> IMIntegration:
|
||||
"""Update connection diagnostics without advancing configuration."""
|
||||
|
||||
return replace(
|
||||
self,
|
||||
status=status,
|
||||
safe_status_reason=safe_status_reason,
|
||||
last_checked_at=checked_at,
|
||||
updated_at=checked_at,
|
||||
)
|
||||
@@ -1,123 +0,0 @@
|
||||
"""Transaction-oriented persistence ports for IM Control Plane invariants.
|
||||
|
||||
Implementations own CAS predicates, Integration row locks, eager loading,
|
||||
revision-guarded apply, rollback, and append-only result persistence. Generic
|
||||
table CRUD is intentionally absent.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Protocol
|
||||
|
||||
from core.human_input_v2.entities import IMProvider
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
IMSyncRunId,
|
||||
IntegrationId,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
)
|
||||
|
||||
from .binding_resolution import BindingResolutionResult
|
||||
from .integration import (
|
||||
ConfigurationTransition,
|
||||
IMIntegration,
|
||||
IntegrationDeletion,
|
||||
IntegrationRevisionToken,
|
||||
StaleRevision,
|
||||
)
|
||||
from .sync_reconciliation import IMSyncRun, ReconciliationPlan, ReconciliationSnapshot, SyncResultFact
|
||||
|
||||
|
||||
class ActiveRunDecisionKind(StrEnum):
|
||||
"""Outcome of Integration-locked sync run creation."""
|
||||
|
||||
CREATED = "created"
|
||||
EXISTING_ACTIVE = "existing_active"
|
||||
STALE_REVISION = "stale_revision"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ActiveRunDecision:
|
||||
"""New or existing active run, or a stable stale-revision rejection."""
|
||||
|
||||
kind: ActiveRunDecisionKind
|
||||
run: IMSyncRun | None
|
||||
stale_revision: StaleRevision | None = None
|
||||
|
||||
|
||||
class ApplyReconciliationStatus(StrEnum):
|
||||
"""Stable outcome of one idempotent revision-guarded apply."""
|
||||
|
||||
APPLIED = "applied"
|
||||
ALREADY_APPLIED = "already_applied"
|
||||
STALE_REVISION = "stale_revision"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplyReconciliationResult:
|
||||
"""Run and append-only facts returned after reconciliation apply."""
|
||||
|
||||
status: ApplyReconciliationStatus
|
||||
run: IMSyncRun
|
||||
results: tuple[SyncResultFact, ...]
|
||||
|
||||
|
||||
class IMControlPlaneRepository(Protocol):
|
||||
"""Atomic persistence capabilities required by the IM domain."""
|
||||
|
||||
def create_integration(self, integration: IMIntegration) -> IMIntegration:
|
||||
"""Create the first integration configuration for its owner scope."""
|
||||
...
|
||||
|
||||
def compare_and_swap_configuration(self, transition: ConfigurationTransition) -> IMIntegration | StaleRevision:
|
||||
"""Atomically apply rotation or replacement and its invalidation plan."""
|
||||
...
|
||||
|
||||
def compare_and_swap_delete(self, deletion: IntegrationDeletion) -> None | StaleRevision:
|
||||
"""Delete current configuration and current children under complete CAS."""
|
||||
...
|
||||
|
||||
def create_or_get_active_run(
|
||||
self,
|
||||
integration_revision: IntegrationRevisionToken,
|
||||
*,
|
||||
sync_run_id: IMSyncRunId,
|
||||
started_by_account_id: AccountId | None,
|
||||
now: UtcTimestamp,
|
||||
) -> ActiveRunDecision:
|
||||
"""Lock Integration and return at most one active run."""
|
||||
...
|
||||
|
||||
def load_reconciliation_snapshot(self, sync_run_id: IMSyncRunId) -> ReconciliationSnapshot:
|
||||
"""Load current identities, bindings, and eligible Contact facts."""
|
||||
...
|
||||
|
||||
def apply_reconciliation(self, plan: ReconciliationPlan, *, now: UtcTimestamp) -> ApplyReconciliationResult:
|
||||
"""Apply one plan using its persisted sync run capture as CAS authority."""
|
||||
...
|
||||
|
||||
def resolve_effective_binding(
|
||||
self,
|
||||
*,
|
||||
integration_id: IntegrationId,
|
||||
provider: IMProvider,
|
||||
workspace_id: WorkspaceId,
|
||||
contact_id: ContactId,
|
||||
) -> BindingResolutionResult:
|
||||
"""Load and resolve one credential-free effective binding snapshot."""
|
||||
...
|
||||
|
||||
def append_sync_results(self, results: tuple[SyncResultFact, ...]) -> None:
|
||||
"""Append diagnostic result facts without changing current state."""
|
||||
...
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ActiveRunDecision",
|
||||
"ActiveRunDecisionKind",
|
||||
"ApplyReconciliationResult",
|
||||
"ApplyReconciliationStatus",
|
||||
"IMControlPlaneRepository",
|
||||
]
|
||||
@@ -1,145 +0,0 @@
|
||||
"""Current IM identity and binding values shared by sync and resolution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pydantic import JsonValue
|
||||
|
||||
from core.human_input_v2.entities import IMBindingScope, IMProvider
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IMSyncRunId,
|
||||
IntegrationId,
|
||||
NormalizedEmail,
|
||||
UtcTimestamp,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OpaqueProviderPayload:
|
||||
"""Immutable provider JSON retained only for persistence diagnostics."""
|
||||
|
||||
_serialized: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, values: Mapping[str, JsonValue]) -> OpaqueProviderPayload:
|
||||
return cls(json.dumps(dict(values), sort_keys=True, separators=(",", ":")))
|
||||
|
||||
def to_mapping(self) -> dict[str, JsonValue]:
|
||||
value = json.loads(self._serialized)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("provider payload must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMIdentity:
|
||||
"""Current provider identity independent from ORM lifetime and raw clients."""
|
||||
|
||||
id: IMIdentityId
|
||||
integration_id: IntegrationId
|
||||
provider: IMProvider
|
||||
provider_user_id: str
|
||||
display_name: str | None
|
||||
normalized_name: str | None
|
||||
email: str | None
|
||||
normalized_email: NormalizedEmail | None
|
||||
raw_payload: OpaqueProviderPayload
|
||||
last_seen_sync_run_id: IMSyncRunId | None
|
||||
last_seen_at: UtcTimestamp | None
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.provider_user_id.strip():
|
||||
raise ValueError("provider user id must not be blank")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
identity_id: IMIdentityId,
|
||||
integration_id: IntegrationId,
|
||||
provider: IMProvider,
|
||||
provider_user_id: str,
|
||||
display_name: str | None,
|
||||
email: str | None,
|
||||
raw_payload: Mapping[str, JsonValue],
|
||||
last_seen_sync_run_id: IMSyncRunId | None,
|
||||
last_seen_at: UtcTimestamp | None,
|
||||
now: UtcTimestamp,
|
||||
created_at: UtcTimestamp | None = None,
|
||||
) -> IMIdentity:
|
||||
clean_name = display_name.strip() if display_name is not None else None
|
||||
clean_email = email.strip() if email is not None else None
|
||||
return cls(
|
||||
id=identity_id,
|
||||
integration_id=integration_id,
|
||||
provider=provider,
|
||||
provider_user_id=provider_user_id.strip(),
|
||||
display_name=clean_name,
|
||||
normalized_name=clean_name.casefold() if clean_name else None,
|
||||
email=clean_email,
|
||||
normalized_email=NormalizedEmail(clean_email) if clean_email else None,
|
||||
raw_payload=OpaqueProviderPayload.from_mapping(raw_payload),
|
||||
last_seen_sync_run_id=last_seen_sync_run_id,
|
||||
last_seen_at=last_seen_at,
|
||||
created_at=created_at or now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMBinding:
|
||||
"""Current Contact-to-provider identity association in one resolution scope."""
|
||||
|
||||
id: IMBindingId
|
||||
integration_id: IntegrationId
|
||||
scope: IMBindingScope
|
||||
scope_id: str
|
||||
contact_id: ContactId
|
||||
identity_id: IMIdentityId
|
||||
provider: IMProvider
|
||||
bound_by_account_id: AccountId | None
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.scope_id.strip():
|
||||
raise ValueError("binding scope id must not be blank")
|
||||
if self.scope is IMBindingScope.ORGANIZATION and self.scope_id != str(self.integration_id):
|
||||
raise ValueError("organization binding scope must be its integration")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
binding_id: IMBindingId,
|
||||
integration_id: IntegrationId,
|
||||
scope: IMBindingScope,
|
||||
scope_id: str,
|
||||
contact_id: ContactId,
|
||||
identity_id: IMIdentityId,
|
||||
provider: IMProvider,
|
||||
bound_by_account_id: AccountId | None,
|
||||
now: UtcTimestamp,
|
||||
created_at: UtcTimestamp | None = None,
|
||||
) -> IMBinding:
|
||||
return cls(
|
||||
id=binding_id,
|
||||
integration_id=integration_id,
|
||||
scope=scope,
|
||||
scope_id=scope_id,
|
||||
contact_id=contact_id,
|
||||
identity_id=identity_id,
|
||||
provider=provider,
|
||||
bound_by_account_id=bound_by_account_id,
|
||||
created_at=created_at or now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -1,18 +0,0 @@
|
||||
"""Explicit aggregate-load snapshot spanning IM persistence records."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .integration import IMIntegration
|
||||
from .records import IMBinding, IMIdentity
|
||||
from .sync_reconciliation import IMSyncRun, SyncResultFact
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMIntegrationState:
|
||||
"""Eagerly loaded Integration with mapped current and historical children."""
|
||||
|
||||
integration: IMIntegration
|
||||
identities: tuple[IMIdentity, ...]
|
||||
bindings: tuple[IMBinding, ...]
|
||||
sync_runs: tuple[IMSyncRun, ...]
|
||||
sync_results: tuple[SyncResultFact, ...]
|
||||
@@ -1,276 +0,0 @@
|
||||
"""Pure provider directory matching and immutable reconciliation plans."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import JsonValue
|
||||
|
||||
from core.human_input_v2.contact_directory import ContactIdentitySource, ContactSnapshot
|
||||
from core.human_input_v2.entities import IMProvider, IMSyncRemovalReason, IMSyncResultType, IMSyncRunStatus
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IMSyncResultId,
|
||||
IMSyncRunId,
|
||||
IntegrationId,
|
||||
NormalizedEmail,
|
||||
UtcTimestamp,
|
||||
)
|
||||
|
||||
from .integration import IntegrationRevisionToken
|
||||
from .records import IMBinding, IMIdentity, OpaqueProviderPayload
|
||||
|
||||
|
||||
class MatchKind(StrEnum):
|
||||
"""Stable explanation for how one provider entry was classified."""
|
||||
|
||||
PROVIDER_USER_ID = "provider_user_id"
|
||||
NORMALIZED_EMAIL = "normalized_email"
|
||||
UNMATCHED = "unmatched"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProviderDirectoryEntry:
|
||||
"""Provider-neutral directory values consumed by the pure reconciler."""
|
||||
|
||||
provider_user_id: str
|
||||
display_name: str | None
|
||||
email: str | None
|
||||
normalized_email: NormalizedEmail | None
|
||||
raw_payload: OpaqueProviderPayload
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
provider_user_id: str,
|
||||
display_name: str | None,
|
||||
email: str | None,
|
||||
raw_payload: dict[str, JsonValue],
|
||||
) -> ProviderDirectoryEntry:
|
||||
clean_email = email.strip() if email is not None else None
|
||||
return cls(
|
||||
provider_user_id=provider_user_id.strip(),
|
||||
display_name=display_name.strip() if display_name is not None else None,
|
||||
email=clean_email,
|
||||
normalized_email=NormalizedEmail(clean_email) if clean_email else None,
|
||||
raw_payload=OpaqueProviderPayload.from_mapping(raw_payload),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReconciliationSnapshot:
|
||||
"""Coherent current facts loaded before provider entries are matched."""
|
||||
|
||||
identities: tuple[IMIdentity, ...] = ()
|
||||
bindings: tuple[IMBinding, ...] = ()
|
||||
contacts: tuple[ContactSnapshot, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReconciliationAction:
|
||||
"""One provider entry match without persistence side effects."""
|
||||
|
||||
entry: ProviderDirectoryEntry
|
||||
match_kind: MatchKind
|
||||
identity_id: IMIdentityId | None
|
||||
binding_id: IMBindingId | None
|
||||
contact_id: ContactId | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReconciliationPlan:
|
||||
"""Immutable plan whose captured revision must be checked again at apply."""
|
||||
|
||||
sync_run_id: IMSyncRunId
|
||||
integration_revision: IntegrationRevisionToken
|
||||
provider: IMProvider
|
||||
actions: tuple[ReconciliationAction, ...]
|
||||
removed_identity_ids: tuple[IMIdentityId, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SyncResultFact:
|
||||
"""Append-only outcome for one action, removed binding, or diagnostic.
|
||||
|
||||
Removing an identity emits one fact per removed binding so every scope
|
||||
override remains auditable. An identity without bindings emits one fact
|
||||
whose binding and Contact fields are absent.
|
||||
"""
|
||||
|
||||
id: IMSyncResultId
|
||||
integration_id: IntegrationId
|
||||
sync_run_id: IMSyncRunId
|
||||
result_type: IMSyncResultType
|
||||
provider_user_id: str | None
|
||||
display_name: str | None
|
||||
email: str | None
|
||||
normalized_email: NormalizedEmail | None
|
||||
contact_id: ContactId | None
|
||||
identity_id: IMIdentityId | None
|
||||
binding_id: IMBindingId | None
|
||||
removal_reason: IMSyncRemovalReason | None
|
||||
reason_code: str | None
|
||||
reason_message: str | None
|
||||
directory_entry_payload: OpaqueProviderPayload | None
|
||||
contact_snapshot: SyncContactSnapshot | None
|
||||
identity_snapshot: SyncIdentitySnapshot | None
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SyncContactSnapshot:
|
||||
"""Immutable Contact display values retained by a historical result."""
|
||||
|
||||
contact_id: ContactId
|
||||
name: str
|
||||
email: str | None
|
||||
avatar_file_id: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SyncIdentitySnapshot:
|
||||
"""Immutable last-known provider identity retained after current deletion."""
|
||||
|
||||
identity_id: IMIdentityId
|
||||
provider: IMProvider
|
||||
provider_user_id: str
|
||||
display_name: str | None
|
||||
email: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMSyncRun:
|
||||
"""Independent sync aggregate that captures one complete Integration token."""
|
||||
|
||||
id: IMSyncRunId
|
||||
integration_revision: IntegrationRevisionToken
|
||||
provider: IMProvider
|
||||
status: IMSyncRunStatus
|
||||
added_count: int
|
||||
not_matched_count: int
|
||||
failed_count: int
|
||||
removed_count: int
|
||||
skipped_count: int
|
||||
started_by_account_id: AccountId | None
|
||||
started_at: UtcTimestamp | None
|
||||
finished_at: UtcTimestamp | None
|
||||
error_code: str | None
|
||||
error_message: str | None
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
sync_run_id: IMSyncRunId,
|
||||
integration_revision: IntegrationRevisionToken,
|
||||
provider: IMProvider,
|
||||
started_by_account_id: AccountId | None,
|
||||
now: UtcTimestamp,
|
||||
) -> IMSyncRun:
|
||||
return cls(
|
||||
id=sync_run_id,
|
||||
integration_revision=integration_revision,
|
||||
provider=provider,
|
||||
status=IMSyncRunStatus.QUEUED,
|
||||
added_count=0,
|
||||
not_matched_count=0,
|
||||
failed_count=0,
|
||||
removed_count=0,
|
||||
skipped_count=0,
|
||||
started_by_account_id=started_by_account_id,
|
||||
started_at=None,
|
||||
finished_at=None,
|
||||
error_code=None,
|
||||
error_message=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.status in (IMSyncRunStatus.QUEUED, IMSyncRunStatus.RUNNING)
|
||||
|
||||
def start(self, now: UtcTimestamp) -> IMSyncRun:
|
||||
if self.status is not IMSyncRunStatus.QUEUED:
|
||||
return self
|
||||
return replace(self, status=IMSyncRunStatus.RUNNING, started_at=now, updated_at=now)
|
||||
|
||||
|
||||
class SyncReconciler:
|
||||
"""Stateless matching policy with no provider or persistence dependencies.
|
||||
|
||||
Email fallback accepts available account-backed Contacts: EE Organization
|
||||
Accounts and CE/SaaS workspace members. External Contacts never participate.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def reconcile(
|
||||
*,
|
||||
sync_run_id: IMSyncRunId,
|
||||
integration_revision: IntegrationRevisionToken,
|
||||
provider: IMProvider,
|
||||
entries: tuple[ProviderDirectoryEntry, ...],
|
||||
snapshot: ReconciliationSnapshot,
|
||||
) -> ReconciliationPlan:
|
||||
identities = {
|
||||
identity.provider_user_id: identity
|
||||
for identity in snapshot.identities
|
||||
if identity.integration_id == integration_revision.integration_id and identity.provider is provider
|
||||
}
|
||||
bindings_by_identity: dict[IMIdentityId, IMBinding] = {}
|
||||
for binding in sorted(snapshot.bindings, key=lambda item: item.scope.value, reverse=True):
|
||||
bindings_by_identity.setdefault(binding.identity_id, binding)
|
||||
contacts_by_email = {
|
||||
item.contact.normalized_email: item.contact
|
||||
for item in snapshot.contacts
|
||||
if item.account_available
|
||||
and item.contact.identity_source
|
||||
in (ContactIdentitySource.ORGANIZATION_ACCOUNT, ContactIdentitySource.WORKSPACE_MEMBER)
|
||||
and item.contact.normalized_email is not None
|
||||
}
|
||||
|
||||
actions: list[ReconciliationAction] = []
|
||||
seen_provider_user_ids: set[str] = set()
|
||||
for entry in entries:
|
||||
seen_provider_user_ids.add(entry.provider_user_id)
|
||||
identity = identities.get(entry.provider_user_id)
|
||||
if identity is not None:
|
||||
matched_binding = bindings_by_identity.get(identity.id)
|
||||
actions.append(
|
||||
ReconciliationAction(
|
||||
entry=entry,
|
||||
match_kind=MatchKind.PROVIDER_USER_ID,
|
||||
identity_id=identity.id,
|
||||
binding_id=matched_binding.id if matched_binding is not None else None,
|
||||
contact_id=matched_binding.contact_id if matched_binding is not None else None,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
contact = contacts_by_email.get(entry.normalized_email) if entry.normalized_email is not None else None
|
||||
actions.append(
|
||||
ReconciliationAction(
|
||||
entry=entry,
|
||||
match_kind=MatchKind.NORMALIZED_EMAIL if contact is not None else MatchKind.UNMATCHED,
|
||||
identity_id=None,
|
||||
binding_id=None,
|
||||
contact_id=contact.id if contact is not None else None,
|
||||
)
|
||||
)
|
||||
|
||||
removed = tuple(
|
||||
identity.id
|
||||
for identity in snapshot.identities
|
||||
if identity.integration_id == integration_revision.integration_id
|
||||
and identity.provider is provider
|
||||
and identity.provider_user_id not in seen_provider_user_ids
|
||||
)
|
||||
return ReconciliationPlan(sync_run_id, integration_revision, provider, tuple(actions), removed)
|
||||
@@ -1,63 +0,0 @@
|
||||
"""Stable Human Input v2 values shared across domain contexts.
|
||||
|
||||
This package contains infrastructure-free values only. Feature-specific ownership
|
||||
and lifecycle rules belong to their bounded context rather than this package.
|
||||
"""
|
||||
|
||||
from .values import (
|
||||
AccountId,
|
||||
AppId,
|
||||
ApproverGrantId,
|
||||
AuditEventId,
|
||||
ContactId,
|
||||
DeliveryAttemptId,
|
||||
DeliveryEndpointId,
|
||||
DeploymentScope,
|
||||
DirectoryScope,
|
||||
EmailProviderId,
|
||||
EndUserId,
|
||||
FormId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IMSyncResultId,
|
||||
IMSyncRunId,
|
||||
IntegrationId,
|
||||
NormalizedEmail,
|
||||
OTPChallengeId,
|
||||
PlatformEntryId,
|
||||
SubmissionId,
|
||||
UploadCapabilityId,
|
||||
UploadFileAssociationId,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
WorkspaceScope,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AccountId",
|
||||
"AppId",
|
||||
"ApproverGrantId",
|
||||
"AuditEventId",
|
||||
"ContactId",
|
||||
"DeliveryAttemptId",
|
||||
"DeliveryEndpointId",
|
||||
"DeploymentScope",
|
||||
"DirectoryScope",
|
||||
"EmailProviderId",
|
||||
"EndUserId",
|
||||
"FormId",
|
||||
"IMBindingId",
|
||||
"IMIdentityId",
|
||||
"IMSyncResultId",
|
||||
"IMSyncRunId",
|
||||
"IntegrationId",
|
||||
"NormalizedEmail",
|
||||
"OTPChallengeId",
|
||||
"PlatformEntryId",
|
||||
"SubmissionId",
|
||||
"UploadCapabilityId",
|
||||
"UploadFileAssociationId",
|
||||
"UtcTimestamp",
|
||||
"WorkspaceId",
|
||||
"WorkspaceScope",
|
||||
]
|
||||
@@ -1,173 +0,0 @@
|
||||
"""Primitive-independent identifiers, scopes, email, and time values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import override
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Identifier:
|
||||
"""Non-empty string identifier with explicit primitive serialization."""
|
||||
|
||||
value: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.value, str) or not self.value.strip():
|
||||
raise ValueError(f"{type(self).__name__} must not be blank")
|
||||
object.__setattr__(self, "value", self.value.strip())
|
||||
|
||||
@override
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
def to_primitive(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class AccountId(_Identifier):
|
||||
"""Identifier of an Account record."""
|
||||
|
||||
|
||||
class ContactId(_Identifier):
|
||||
"""Identifier of a canonical Contact."""
|
||||
|
||||
|
||||
class EndUserId(_Identifier):
|
||||
"""Identifier of one app-scoped EndUser identity."""
|
||||
|
||||
|
||||
class PlatformEntryId(_Identifier):
|
||||
"""Identifier of one Platform allow-list entry."""
|
||||
|
||||
|
||||
class WorkspaceId(_Identifier):
|
||||
"""Identifier of the workspace that owns or resolves a Contact."""
|
||||
|
||||
|
||||
class AppId(_Identifier):
|
||||
"""Identifier of the application that owns a Human Input form."""
|
||||
|
||||
|
||||
class FormId(_Identifier):
|
||||
"""Identifier of one Human Input v2 form root."""
|
||||
|
||||
|
||||
class ApproverGrantId(_Identifier):
|
||||
"""Identifier of one form-scoped approver grant."""
|
||||
|
||||
|
||||
class OTPChallengeId(_Identifier):
|
||||
"""Identifier of one grant-scoped OTP proof session."""
|
||||
|
||||
|
||||
class DeliveryEndpointId(_Identifier):
|
||||
"""Identifier of one frozen form delivery endpoint."""
|
||||
|
||||
|
||||
class DeliveryAttemptId(_Identifier):
|
||||
"""Identifier of one append-only delivery attempt."""
|
||||
|
||||
|
||||
class SubmissionId(_Identifier):
|
||||
"""Identifier of one immutable winning form submission."""
|
||||
|
||||
|
||||
class AuditEventId(_Identifier):
|
||||
"""Identifier of one append-only Human Input audit event."""
|
||||
|
||||
|
||||
class EmailProviderId(_Identifier):
|
||||
"""Identifier of one workspace email provider configuration."""
|
||||
|
||||
|
||||
class UploadCapabilityId(_Identifier):
|
||||
"""Identifier of one endpoint-scoped upload capability."""
|
||||
|
||||
|
||||
class UploadFileAssociationId(_Identifier):
|
||||
"""Identifier of one durable uploaded-file association."""
|
||||
|
||||
|
||||
class IntegrationId(_Identifier):
|
||||
"""Identifier of one IM Integration configuration identity."""
|
||||
|
||||
|
||||
class IMIdentityId(_Identifier):
|
||||
"""Identifier of one current synchronized provider identity."""
|
||||
|
||||
|
||||
class IMBindingId(_Identifier):
|
||||
"""Identifier of one current Contact-to-IM-identity binding."""
|
||||
|
||||
|
||||
class IMSyncRunId(_Identifier):
|
||||
"""Identifier of one IM directory synchronization run."""
|
||||
|
||||
|
||||
class IMSyncResultId(_Identifier):
|
||||
"""Identifier of one append-only synchronization result fact."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NormalizedEmail:
|
||||
"""Case-insensitive canonical email used for identity comparisons."""
|
||||
|
||||
value: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.value, str):
|
||||
raise ValueError("value must be a valid email")
|
||||
normalized = self.value.strip().casefold()
|
||||
local, separator, domain = normalized.partition("@")
|
||||
if not separator or not local or not domain or " " in normalized or "@" in domain:
|
||||
raise ValueError("value must be a valid email")
|
||||
object.__setattr__(self, "value", normalized)
|
||||
|
||||
@override
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
def to_primitive(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeploymentScope:
|
||||
"""Deployment-wide owner scope used by EE Organization contacts."""
|
||||
|
||||
def to_primitive(self) -> dict[str, str]:
|
||||
return {"kind": "deployment"}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkspaceScope:
|
||||
"""Owner scope for workspace-owned contacts and directory operations."""
|
||||
|
||||
workspace_id: WorkspaceId
|
||||
|
||||
def to_primitive(self) -> dict[str, str]:
|
||||
return {"kind": "workspace", "workspace_id": self.workspace_id.to_primitive()}
|
||||
|
||||
|
||||
type DirectoryScope = DeploymentScope | WorkspaceScope
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UtcTimestamp:
|
||||
"""Timezone-aware timestamp normalized to UTC at construction."""
|
||||
|
||||
value: datetime
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.value, datetime) or self.value.tzinfo is None or self.value.utcoffset() is None:
|
||||
raise ValueError("value must be a timezone-aware datetime")
|
||||
object.__setattr__(self, "value", self.value.astimezone(UTC))
|
||||
|
||||
@classmethod
|
||||
def now(cls) -> UtcTimestamp:
|
||||
return cls(datetime.now(UTC))
|
||||
|
||||
def to_primitive(self) -> str:
|
||||
return self.value.isoformat().replace("+00:00", "Z")
|
||||
@@ -66,7 +66,7 @@ from services.enterprise.plugin_manager_service import (
|
||||
PreUninstallPluginRequest,
|
||||
)
|
||||
from services.errors.plugin import PluginInstallationForbiddenError
|
||||
from services.feature_service import FeatureService, PluginInstallationScope
|
||||
from services.feature_service import FeatureService, PluginInstallationPermissionModel, PluginInstallationScope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_provider_entities_adapter: TypeAdapter[list[ProviderEntity]] = TypeAdapter(list[ProviderEntity])
|
||||
@@ -434,14 +434,18 @@ class PluginService:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _fetch_plugin_model_providers_uncached(
|
||||
cls, tenant_id: str, client: PluginModelClient | None
|
||||
) -> tuple[ProviderEntity, ...]:
|
||||
model_client = client or PluginModelClient()
|
||||
return tuple(cls._to_provider_entity(provider) for provider in model_client.fetch_model_providers(tenant_id))
|
||||
|
||||
@classmethod
|
||||
def _fetch_and_cache_plugin_model_providers(
|
||||
cls, tenant_id: str, client: PluginModelClient | None, *, refresh_generation: int | None
|
||||
) -> tuple[ProviderEntity, ...]:
|
||||
model_client = client or PluginModelClient()
|
||||
providers = tuple(
|
||||
cls._to_provider_entity(provider) for provider in model_client.fetch_model_providers(tenant_id)
|
||||
)
|
||||
providers = cls._fetch_plugin_model_providers_uncached(tenant_id, client)
|
||||
generation = cls._load_plugin_model_providers_generation(tenant_id)
|
||||
if generation is not None and generation == refresh_generation:
|
||||
cls._store_cached_plugin_model_providers(tenant_id, generation, providers)
|
||||
@@ -471,6 +475,9 @@ class PluginService:
|
||||
are intentionally owned by this service so tenant isolation and cache
|
||||
expiry are handled in one place.
|
||||
"""
|
||||
if not dify_config.PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED:
|
||||
return cls._fetch_plugin_model_providers_uncached(tenant_id, client)
|
||||
|
||||
deadline = time.monotonic() + cls.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT
|
||||
|
||||
while True:
|
||||
@@ -597,22 +604,30 @@ class PluginService:
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _check_marketplace_only_permission():
|
||||
def _check_marketplace_only_permission() -> None:
|
||||
"""
|
||||
Check if the marketplace only permission is enabled
|
||||
"""
|
||||
features = FeatureService.get_system_features()
|
||||
if features.plugin_installation_permission.restrict_to_marketplace_only:
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
if permission.restrict_to_marketplace_only:
|
||||
raise PluginInstallationForbiddenError("Plugin installation is restricted to marketplace only")
|
||||
|
||||
@staticmethod
|
||||
def _check_plugin_installation_scope(plugin_verification: PluginVerification | None):
|
||||
def _get_plugin_installation_permission() -> PluginInstallationPermissionModel:
|
||||
"""Resolve the validated policy and reject deny-all before any installation side effect."""
|
||||
permission = FeatureService.get_plugin_installation_permission()
|
||||
if permission.plugin_installation_scope == PluginInstallationScope.NONE:
|
||||
raise PluginInstallationForbiddenError("Installing plugins is not allowed")
|
||||
return permission
|
||||
|
||||
@staticmethod
|
||||
def _check_plugin_installation_scope(plugin_verification: PluginVerification | None) -> None:
|
||||
"""
|
||||
Check the plugin installation scope
|
||||
"""
|
||||
features = FeatureService.get_system_features()
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
|
||||
match features.plugin_installation_permission.plugin_installation_scope:
|
||||
match permission.plugin_installation_scope:
|
||||
case PluginInstallationScope.OFFICIAL_ONLY:
|
||||
if (
|
||||
plugin_verification is None
|
||||
@@ -627,10 +642,10 @@ class PluginService:
|
||||
raise PluginInstallationForbiddenError(
|
||||
"Plugin installation is restricted to official and specific partners"
|
||||
)
|
||||
case PluginInstallationScope.NONE:
|
||||
raise PluginInstallationForbiddenError("Installing plugins is not allowed")
|
||||
case PluginInstallationScope.ALL:
|
||||
pass
|
||||
case _:
|
||||
raise PluginInstallationForbiddenError("Plugin installation policy is invalid")
|
||||
|
||||
@staticmethod
|
||||
def get_debugging_key(tenant_id: str) -> str:
|
||||
@@ -900,7 +915,7 @@ class PluginService:
|
||||
# check if plugin pkg is already downloaded
|
||||
manager = PluginInstaller()
|
||||
|
||||
features = FeatureService.get_system_features()
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
|
||||
try:
|
||||
manager.fetch_plugin_manifest(tenant_id, new_plugin_unique_identifier)
|
||||
@@ -912,7 +927,7 @@ class PluginService:
|
||||
response = manager.upload_pkg(
|
||||
tenant_id,
|
||||
pkg,
|
||||
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
|
||||
verify_signature=permission.restrict_to_marketplace_only,
|
||||
)
|
||||
|
||||
# check if the plugin is available to install
|
||||
@@ -967,11 +982,11 @@ class PluginService:
|
||||
"""
|
||||
PluginService._check_marketplace_only_permission()
|
||||
manager = PluginInstaller()
|
||||
features = FeatureService.get_system_features()
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
response = manager.upload_pkg(
|
||||
tenant_id,
|
||||
pkg,
|
||||
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
|
||||
verify_signature=permission.restrict_to_marketplace_only,
|
||||
)
|
||||
PluginService._check_plugin_installation_scope(response.verification)
|
||||
|
||||
@@ -989,13 +1004,13 @@ class PluginService:
|
||||
pkg = download_with_size_limit(
|
||||
f"https://github.com/{repo}/releases/download/{version}/{package}", dify_config.PLUGIN_MAX_PACKAGE_SIZE
|
||||
)
|
||||
features = FeatureService.get_system_features()
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
|
||||
manager = PluginInstaller()
|
||||
response = manager.upload_pkg(
|
||||
tenant_id,
|
||||
pkg,
|
||||
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
|
||||
verify_signature=permission.restrict_to_marketplace_only,
|
||||
)
|
||||
PluginService._check_plugin_installation_scope(response.verification)
|
||||
|
||||
@@ -1069,7 +1084,7 @@ class PluginService:
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
raise ValueError("marketplace is not enabled")
|
||||
|
||||
features = FeatureService.get_system_features()
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
|
||||
manager = PluginInstaller()
|
||||
try:
|
||||
@@ -1079,7 +1094,7 @@ class PluginService:
|
||||
response = manager.upload_pkg(
|
||||
tenant_id,
|
||||
pkg,
|
||||
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
|
||||
verify_signature=permission.restrict_to_marketplace_only,
|
||||
)
|
||||
# check if the plugin is available to install
|
||||
PluginService._check_plugin_installation_scope(response.verification)
|
||||
@@ -1101,7 +1116,7 @@ class PluginService:
|
||||
# collect actual plugin_unique_identifiers
|
||||
actual_plugin_unique_identifiers = []
|
||||
metas = []
|
||||
features = FeatureService.get_system_features()
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
|
||||
# check if already downloaded
|
||||
for plugin_unique_identifier in plugin_unique_identifiers:
|
||||
@@ -1119,7 +1134,7 @@ class PluginService:
|
||||
response = manager.upload_pkg(
|
||||
tenant_id,
|
||||
pkg,
|
||||
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
|
||||
verify_signature=permission.restrict_to_marketplace_only,
|
||||
)
|
||||
# check if the plugin is available to install
|
||||
PluginService._check_plugin_installation_scope(response.verification)
|
||||
|
||||
@@ -16,6 +16,9 @@ from core.repositories.factory import (
|
||||
OrderConfig,
|
||||
WorkflowNodeExecutionRepository,
|
||||
)
|
||||
from core.repositories.sqlalchemy_workflow_node_execution_repository import (
|
||||
SQLAlchemyWorkflowNodeExecutionRepository,
|
||||
)
|
||||
from graphon.entities import WorkflowNodeExecution
|
||||
from models import Account, CreatorUserRole, EndUser
|
||||
from models.workflow import WorkflowNodeExecutionTriggeredFrom
|
||||
@@ -49,6 +52,7 @@ class CeleryWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository):
|
||||
_creator_user_role: CreatorUserRole
|
||||
_execution_cache: dict[str, WorkflowNodeExecution]
|
||||
_workflow_execution_mapping: dict[str, list[str]]
|
||||
_sql_repository: SQLAlchemyWorkflowNodeExecutionRepository
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -98,6 +102,13 @@ class CeleryWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository):
|
||||
|
||||
# Cache for mapping workflow_execution_ids to execution IDs for efficient retrieval
|
||||
self._workflow_execution_mapping = {}
|
||||
self._sql_repository = SQLAlchemyWorkflowNodeExecutionRepository(
|
||||
session_factory=session_factory,
|
||||
tenant_id=tenant_id,
|
||||
user=user,
|
||||
app_id=app_id,
|
||||
triggered_from=triggered_from,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Initialized CeleryWorkflowNodeExecutionRepository for tenant %s, app %s, triggered_from %s",
|
||||
@@ -149,6 +160,17 @@ class CeleryWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository):
|
||||
# For now, we'll re-raise the exception
|
||||
raise
|
||||
|
||||
@override
|
||||
def save_synchronously(self, execution: WorkflowNodeExecution) -> None:
|
||||
"""Create the Agent v2 caller row before runtime participant allocation."""
|
||||
|
||||
self._sql_repository.save_synchronously(execution)
|
||||
self._execution_cache[execution.id] = execution
|
||||
if execution.workflow_execution_id:
|
||||
execution_ids = self._workflow_execution_mapping.setdefault(execution.workflow_execution_id, [])
|
||||
if execution.id not in execution_ids:
|
||||
execution_ids.append(execution.id)
|
||||
|
||||
@override
|
||||
def get_by_workflow_execution(
|
||||
self,
|
||||
|
||||
@@ -35,6 +35,8 @@ class WorkflowExecutionRepository(Protocol):
|
||||
class WorkflowNodeExecutionRepository(Protocol):
|
||||
def save(self, execution: WorkflowNodeExecution): ...
|
||||
|
||||
def save_synchronously(self, execution: WorkflowNodeExecution) -> None: ...
|
||||
|
||||
def save_execution_data(self, execution: WorkflowNodeExecution): ...
|
||||
|
||||
def get_by_workflow_execution(
|
||||
|
||||
@@ -14,10 +14,6 @@ from core.workflow.human_input_adapter import (
|
||||
EmailDeliveryMethod,
|
||||
EmailRecipients,
|
||||
ExternalRecipient,
|
||||
InstantMessageChannelRecipient,
|
||||
InstantMessageDeliveryConfig,
|
||||
InstantMessageDeliveryMethod,
|
||||
InstantMessageUserRecipient,
|
||||
InteractiveSurfaceDeliveryMethod,
|
||||
is_human_input_webapp_enabled,
|
||||
)
|
||||
@@ -36,7 +32,6 @@ from models.human_input import (
|
||||
HumanInputDelivery,
|
||||
HumanInputForm,
|
||||
HumanInputFormRecipient,
|
||||
InstantMessageRecipientPayload,
|
||||
RecipientType,
|
||||
StandaloneWebAppRecipientPayload,
|
||||
)
|
||||
@@ -328,48 +323,9 @@ class HumanInputFormRepositoryImpl:
|
||||
recipients_config=email_recipients_config,
|
||||
)
|
||||
)
|
||||
case InstantMessageDeliveryMethod():
|
||||
recipients.extend(
|
||||
self._build_instant_message_recipients(
|
||||
form_id=form_id,
|
||||
delivery_id=delivery_id,
|
||||
config=delivery_method.config,
|
||||
)
|
||||
)
|
||||
|
||||
return _DeliveryAndRecipients(delivery=delivery_model, recipients=recipients)
|
||||
|
||||
@staticmethod
|
||||
def _build_instant_message_recipients(
|
||||
*,
|
||||
form_id: str,
|
||||
delivery_id: str,
|
||||
config: InstantMessageDeliveryConfig,
|
||||
) -> list[HumanInputFormRecipient]:
|
||||
recipient_models: list[HumanInputFormRecipient] = []
|
||||
for recipient in config.recipients.items:
|
||||
match recipient:
|
||||
case InstantMessageChannelRecipient():
|
||||
payload = InstantMessageRecipientPayload(
|
||||
provider=config.provider,
|
||||
recipient_kind=recipient.type,
|
||||
channel_id=recipient.channel_id,
|
||||
)
|
||||
case InstantMessageUserRecipient():
|
||||
payload = InstantMessageRecipientPayload(
|
||||
provider=config.provider,
|
||||
recipient_kind=recipient.type,
|
||||
user_id=recipient.user_id,
|
||||
)
|
||||
recipient_models.append(
|
||||
HumanInputFormRecipient.new(
|
||||
form_id=form_id,
|
||||
delivery_id=delivery_id,
|
||||
payload=payload,
|
||||
)
|
||||
)
|
||||
return recipient_models
|
||||
|
||||
def _build_email_recipients(
|
||||
self,
|
||||
session: Session,
|
||||
|
||||
@@ -18,6 +18,7 @@ from tenacity import before_sleep_log, retry, retry_if_exception, stop_after_att
|
||||
|
||||
from configs import dify_config
|
||||
from core.repositories.factory import OrderConfig, WorkflowNodeExecutionRepository
|
||||
from core.workflow.node_execution_process_data import preserve_workflow_agent_binding_id
|
||||
from extensions.ext_storage import storage
|
||||
from graphon.entities import WorkflowNodeExecution
|
||||
from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
|
||||
@@ -372,6 +373,12 @@ class SQLAlchemyWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository)
|
||||
logger.exception("Failed to save workflow node execution after all retries")
|
||||
raise
|
||||
|
||||
@override
|
||||
def save_synchronously(self, execution: WorkflowNodeExecution) -> None:
|
||||
"""Persist a caller row before an Agent v2 participant is materialized."""
|
||||
|
||||
self.save(execution)
|
||||
|
||||
def _persist_to_database(self, db_model: WorkflowNodeExecutionModel):
|
||||
"""
|
||||
Persist the database model to the database.
|
||||
@@ -386,6 +393,13 @@ class SQLAlchemyWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository)
|
||||
existing = session.get(WorkflowNodeExecutionModel, db_model.id)
|
||||
|
||||
if existing:
|
||||
merged_process_data = preserve_workflow_agent_binding_id(
|
||||
existing.process_data_dict,
|
||||
db_model.process_data_dict,
|
||||
)
|
||||
db_model.process_data = (
|
||||
_deterministic_json_dump(merged_process_data) if merged_process_data is not None else None
|
||||
)
|
||||
# Update existing record by copying all non-private attributes
|
||||
for key, value in db_model.__dict__.items():
|
||||
if not key.startswith("_"):
|
||||
@@ -442,18 +456,25 @@ class SQLAlchemyWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository)
|
||||
else:
|
||||
db_model.outputs = self._json_encode(domain_model.outputs)
|
||||
|
||||
if domain_model.process_data is not None:
|
||||
process_data = preserve_workflow_agent_binding_id(db_model.process_data_dict, domain_model.process_data)
|
||||
if process_data is not None:
|
||||
result = self._truncate_and_upload(
|
||||
domain_model.process_data,
|
||||
process_data,
|
||||
domain_model.id,
|
||||
ExecutionOffLoadType.PROCESS_DATA,
|
||||
)
|
||||
if result is not None:
|
||||
db_model.process_data = self._json_encode(result.truncated_value)
|
||||
domain_model.set_truncated_process_data(result.truncated_value)
|
||||
truncated_process_data = preserve_workflow_agent_binding_id(
|
||||
process_data,
|
||||
result.truncated_value,
|
||||
)
|
||||
if truncated_process_data is None:
|
||||
raise ValueError("truncated process data is unavailable")
|
||||
db_model.process_data = self._json_encode(truncated_process_data)
|
||||
domain_model.set_truncated_process_data(truncated_process_data)
|
||||
offload_data = _replace_or_append_offload(offload_data, result.offload)
|
||||
else:
|
||||
db_model.process_data = self._json_encode(domain_model.process_data)
|
||||
db_model.process_data = self._json_encode(process_data)
|
||||
|
||||
db_model.offload_data = offload_data
|
||||
with self._session_factory() as session, session.begin():
|
||||
|
||||
@@ -107,6 +107,8 @@ class MCPTool(Tool):
|
||||
if self.entity.output_schema and result.structuredContent:
|
||||
for k, v in result.structuredContent.items():
|
||||
yield self.create_variable_message(k, v)
|
||||
elif result.structuredContent:
|
||||
yield self.create_json_message(result.structuredContent)
|
||||
|
||||
def _process_text_content(self, content: TextContent) -> Generator[ToolInvokeMessage, None, None]:
|
||||
"""Process text content and yield appropriate messages."""
|
||||
|
||||
@@ -5,8 +5,6 @@ Dify-owned field spellings and value shapes. Adapt them here before handing the
|
||||
payload to Graphon so Graphon-owned models only see current contracts.
|
||||
"""
|
||||
|
||||
# TODO(QuantumGhost): This should be migrated back to human_input.HumanInputNodeData.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
@@ -28,7 +26,6 @@ from graphon.variables.consts import SELECTORS_LENGTH
|
||||
class DeliveryMethodType(enum.StrEnum):
|
||||
WEBAPP = enum.auto()
|
||||
EMAIL = enum.auto()
|
||||
IM = enum.auto()
|
||||
|
||||
|
||||
class EmailRecipientType(enum.StrEnum):
|
||||
@@ -37,17 +34,6 @@ class EmailRecipientType(enum.StrEnum):
|
||||
EXTERNAL = "external"
|
||||
|
||||
|
||||
class InstantMessageProvider(enum.StrEnum):
|
||||
SLACK = "slack"
|
||||
TEAMS = "teams"
|
||||
DISCORD = "discord"
|
||||
|
||||
|
||||
class InstantMessageRecipientType(enum.StrEnum):
|
||||
CHANNEL = "channel"
|
||||
USER = "user"
|
||||
|
||||
|
||||
class _InteractiveSurfaceDeliveryConfig(BaseModel):
|
||||
pass
|
||||
|
||||
@@ -70,25 +56,6 @@ MemberRecipient = BoundRecipient
|
||||
EmailRecipient = Annotated[BoundRecipient | ExternalRecipient, Field(discriminator="type")]
|
||||
|
||||
|
||||
class InstantMessageChannelRecipient(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type: Literal[InstantMessageRecipientType.CHANNEL] = InstantMessageRecipientType.CHANNEL
|
||||
channel_id: str
|
||||
|
||||
|
||||
class InstantMessageUserRecipient(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type: Literal[InstantMessageRecipientType.USER] = InstantMessageRecipientType.USER
|
||||
user_id: str
|
||||
|
||||
|
||||
InstantMessageRecipient = Annotated[
|
||||
InstantMessageChannelRecipient | InstantMessageUserRecipient, Field(discriminator="type")
|
||||
]
|
||||
|
||||
|
||||
class EmailRecipients(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -99,12 +66,6 @@ class EmailRecipients(BaseModel):
|
||||
items: list[EmailRecipient] = Field(default_factory=list)
|
||||
|
||||
|
||||
class InstantMessageRecipients(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
items: list[InstantMessageRecipient] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EmailDeliveryConfig(BaseModel):
|
||||
URL_PLACEHOLDER: ClassVar[str] = "{{#url#}}"
|
||||
_ALLOWED_HTML_TAGS: ClassVar[list[str]] = [
|
||||
@@ -180,14 +141,6 @@ class EmailDeliveryConfig(BaseModel):
|
||||
return " ".join(sanitized.split())
|
||||
|
||||
|
||||
class InstantMessageDeliveryConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
provider: InstantMessageProvider
|
||||
recipients: InstantMessageRecipients = Field(default_factory=InstantMessageRecipients)
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class _DeliveryMethodBase(BaseModel):
|
||||
enabled: bool = True
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4)
|
||||
@@ -217,31 +170,10 @@ class EmailDeliveryMethod(_DeliveryMethodBase):
|
||||
return selectors
|
||||
|
||||
|
||||
class InstantMessageDeliveryMethod(_DeliveryMethodBase):
|
||||
type: Literal[DeliveryMethodType.IM] = DeliveryMethodType.IM
|
||||
config: InstantMessageDeliveryConfig
|
||||
|
||||
@override
|
||||
def extract_variable_selectors(self) -> Sequence[Sequence[str]]:
|
||||
if not self.config.message:
|
||||
return ()
|
||||
variable_template_parser = VariableTemplateParser(template=self.config.message)
|
||||
selectors: list[Sequence[str]] = []
|
||||
for variable_selector in variable_template_parser.extract_variable_selectors():
|
||||
value_selector = list(variable_selector.value_selector)
|
||||
if len(value_selector) < SELECTORS_LENGTH:
|
||||
continue
|
||||
selectors.append(value_selector[:SELECTORS_LENGTH])
|
||||
return selectors
|
||||
|
||||
|
||||
WebAppDeliveryMethod = InteractiveSurfaceDeliveryMethod
|
||||
_WebAppDeliveryConfig = _InteractiveSurfaceDeliveryConfig
|
||||
|
||||
DeliveryChannelConfig = Annotated[
|
||||
InteractiveSurfaceDeliveryMethod | EmailDeliveryMethod | InstantMessageDeliveryMethod,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
DeliveryChannelConfig = Annotated[InteractiveSurfaceDeliveryMethod | EmailDeliveryMethod, Field(discriminator="type")]
|
||||
|
||||
_DELIVERY_METHODS_ADAPTER = TypeAdapter(list[DeliveryChannelConfig])
|
||||
|
||||
@@ -272,10 +204,9 @@ def adapt_human_input_node_data_for_graph(node_data: Mapping[str, Any] | BaseMod
|
||||
|
||||
config_mapping = _copy_mapping(method_mapping.get("config"))
|
||||
if config_mapping is not None:
|
||||
if method_mapping.get("type") == DeliveryMethodType.EMAIL:
|
||||
recipients_mapping = _copy_mapping(config_mapping.get("recipients"))
|
||||
if recipients_mapping is not None:
|
||||
config_mapping["recipients"] = _normalize_email_recipients(recipients_mapping)
|
||||
recipients_mapping = _copy_mapping(config_mapping.get("recipients"))
|
||||
if recipients_mapping is not None:
|
||||
config_mapping["recipients"] = _normalize_email_recipients(recipients_mapping)
|
||||
method_mapping["config"] = config_mapping
|
||||
|
||||
normalized_methods.append(method_mapping)
|
||||
@@ -446,13 +377,6 @@ __all__ = [
|
||||
"EmailRecipientType",
|
||||
"EmailRecipients",
|
||||
"ExternalRecipient",
|
||||
"InstantMessageChannelRecipient",
|
||||
"InstantMessageDeliveryConfig",
|
||||
"InstantMessageDeliveryMethod",
|
||||
"InstantMessageProvider",
|
||||
"InstantMessageRecipientType",
|
||||
"InstantMessageRecipients",
|
||||
"InstantMessageUserRecipient",
|
||||
"MemberRecipient",
|
||||
"WebAppDeliveryMethod",
|
||||
"_WebAppDeliveryConfig",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
WORKFLOW_AGENT_BINDING_ID_KEY = "workflow_agent_binding_id"
|
||||
|
||||
|
||||
def preserve_workflow_agent_binding_id(
|
||||
identity_source: Mapping[str, Any] | None,
|
||||
process_data: Mapping[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
source_id = (identity_source or {}).get(WORKFLOW_AGENT_BINDING_ID_KEY)
|
||||
target_id = (process_data or {}).get(WORKFLOW_AGENT_BINDING_ID_KEY)
|
||||
for value in (source_id, target_id):
|
||||
if value is not None and not isinstance(value, str):
|
||||
raise ValueError("workflow_agent_binding_id must be a string")
|
||||
if source_id is not None and target_id is not None and source_id != target_id:
|
||||
raise ValueError("workflow_agent_binding_id does not match")
|
||||
|
||||
if process_data is None and source_id is None:
|
||||
return None
|
||||
merged = dict(process_data or {})
|
||||
if source_id is not None:
|
||||
merged[WORKFLOW_AGENT_BINDING_ID_KEY] = source_id
|
||||
return merged
|
||||
|
||||
|
||||
__all__ = ["WORKFLOW_AGENT_BINDING_ID_KEY", "preserve_workflow_agent_binding_id"]
|
||||
@@ -487,7 +487,7 @@ class DifyNodeFactory(NodeFactory):
|
||||
from core.workflow.nodes.agent_v2.output_failure_orchestrator import OutputFailureOrchestrator
|
||||
from core.workflow.nodes.agent_v2.output_file_rebacker import reback_tool_file_output
|
||||
from core.workflow.nodes.agent_v2.output_type_checker import PerOutputTypeChecker
|
||||
from core.workflow.nodes.agent_v2.session_store import WorkflowAgentRuntimeSessionStore
|
||||
from core.workflow.nodes.agent_v2.session_store import WorkflowAgentWorkspaceStore
|
||||
|
||||
return {
|
||||
"binding_resolver": WorkflowAgentBindingResolver(),
|
||||
@@ -512,7 +512,7 @@ class DifyNodeFactory(NodeFactory):
|
||||
# tenant validator resolves ToolFile (canonical) + UploadFile refs.
|
||||
"type_checker": PerOutputTypeChecker(file_validator=AgentOutputFileTenantValidator()),
|
||||
"failure_orchestrator": OutputFailureOrchestrator(),
|
||||
"session_store": WorkflowAgentRuntimeSessionStore(),
|
||||
"session_store": WorkflowAgentWorkspaceStore(),
|
||||
}
|
||||
return {
|
||||
"strategy_resolver": self._agent_strategy_resolver,
|
||||
|
||||
@@ -198,8 +198,23 @@ class AgentRuntimeSupport:
|
||||
if model_schema:
|
||||
model_schema = self._remove_unsupported_model_features_for_old_version(model_schema)
|
||||
value["entity"] = model_schema.model_dump(mode="json")
|
||||
# The model selector value from the workflow frontend only
|
||||
# carries provider/model/mode — it does NOT include
|
||||
# completion_params. AgentStrategy plugins (cot_agent,
|
||||
# function_calling) read completion_params to build the
|
||||
# LLMModelConfig that is backwards-invoked, and some model
|
||||
# providers raise KeyError('required') when
|
||||
# completion_params is empty because their parameter_rules
|
||||
# declare required fields with no default. Populate
|
||||
# completion_params with the defaults declared in the model
|
||||
# schema so the plugin daemon always receives a valid set
|
||||
# of model parameters.
|
||||
if "completion_params" not in value:
|
||||
value["completion_params"] = self._extract_default_completion_params(model_schema)
|
||||
else:
|
||||
value["entity"] = None
|
||||
if "completion_params" not in value:
|
||||
value["completion_params"] = {}
|
||||
result[parameter_name] = value
|
||||
|
||||
return result
|
||||
@@ -275,6 +290,24 @@ class AgentRuntimeSupport:
|
||||
model_schema.features.remove(feature)
|
||||
return model_schema
|
||||
|
||||
@staticmethod
|
||||
def _extract_default_completion_params(model_schema: AIModelEntity) -> dict[str, Any]:
|
||||
"""Build a completion_params dict from the model schema's parameter_rules.
|
||||
|
||||
The workflow Agent node's model-selector parameter only stores
|
||||
provider/model/mode — it never carries completion_params. When the
|
||||
value is forwarded to the plugin daemon, AgentModelConfig defaults
|
||||
completion_params to ``{}``, which causes some model providers to fail
|
||||
because their parameter_rules declare required fields. This helper
|
||||
collects the ``default`` value of every parameter_rule that has one so
|
||||
the plugin daemon receives a valid, non-empty set of model parameters.
|
||||
"""
|
||||
completion_params: dict[str, Any] = {}
|
||||
for rule in model_schema.parameter_rules:
|
||||
if rule.default is not None:
|
||||
completion_params[rule.name] = rule.default
|
||||
return completion_params
|
||||
|
||||
@staticmethod
|
||||
def _filter_mcp_type_tool(
|
||||
strategy: ResolvedAgentStrategy,
|
||||
|
||||
@@ -18,13 +18,10 @@ from clients.agent_backend import (
|
||||
AgentBackendRunEventAdapter,
|
||||
AgentBackendRunFailedInternalEvent,
|
||||
AgentBackendRunSucceededInternalEvent,
|
||||
AgentBackendSessionCleanupPayload,
|
||||
AgentBackendStreamError,
|
||||
AgentBackendStreamInternalEvent,
|
||||
AgentBackendTransportError,
|
||||
AgentBackendValidationError,
|
||||
RuntimeLayerSpec,
|
||||
extract_runtime_layer_specs,
|
||||
)
|
||||
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
|
||||
from core.repositories.human_input_repository import HumanInputFormRepository, HumanInputFormRepositoryImpl
|
||||
@@ -33,11 +30,12 @@ from core.workflow.nodes.human_input.session_binding import default_session_bind
|
||||
from core.workflow.system_variables import SystemVariableKey, get_system_text
|
||||
from graphon.entities.pause_reason import HitlRequired, SchedulingPause
|
||||
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
|
||||
from graphon.node_events import NodeEventBase, NodeRunResult, PauseRequestedEvent, StreamCompletedEvent
|
||||
from graphon.graph_events import NodeRunPauseRequestedEvent
|
||||
from graphon.node_events import NodeEventBase, NodeRunResult, StreamCompletedEvent
|
||||
from graphon.nodes.base.node import Node
|
||||
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
|
||||
from services.agent.prompt_mentions import extract_workflow_node_output_selectors
|
||||
from tasks.agent_backend_session_cleanup_task import cleanup_workflow_agent_runtime_session
|
||||
from services.agent.workspace_service import AgentWorkspaceNotFoundError
|
||||
|
||||
from .ask_human_hitl import AskHumanFormBuildError, build_ask_human_pause_reason
|
||||
from .ask_human_resume import build_deferred_tool_results, resolve_ask_human_form
|
||||
@@ -56,7 +54,7 @@ from .runtime_request_builder import (
|
||||
WorkflowAgentRuntimeRequestBuilder,
|
||||
WorkflowAgentRuntimeRequestBuildError,
|
||||
)
|
||||
from .session_store import WorkflowAgentRuntimeSessionStore, WorkflowAgentSessionScope
|
||||
from .session_store import WorkflowAgentSessionScope, WorkflowAgentWorkspaceStore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from graphon.entities import GraphInitParams
|
||||
@@ -68,7 +66,7 @@ logger = logging.getLogger(__name__)
|
||||
# Stage 4 §5+§7: the terminal events that `_consume_event_stream` may return.
|
||||
# Stream + started events are filtered out before we yield; transport errors
|
||||
# are surfaced as a separate StreamCompletedEvent in the second tuple slot.
|
||||
_TerminalAgentBackendEvent = (
|
||||
type _TerminalAgentBackendEvent = (
|
||||
AgentBackendRunSucceededInternalEvent
|
||||
| AgentBackendRunFailedInternalEvent
|
||||
| AgentBackendRunCancelledInternalEvent
|
||||
@@ -93,7 +91,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
output_adapter: WorkflowAgentOutputAdapter,
|
||||
type_checker: PerOutputTypeChecker,
|
||||
failure_orchestrator: OutputFailureOrchestrator,
|
||||
session_store: WorkflowAgentRuntimeSessionStore | None = None,
|
||||
session_store: WorkflowAgentWorkspaceStore,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
node_id=node_id,
|
||||
@@ -130,7 +128,34 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
return reason
|
||||
|
||||
@override
|
||||
def _run(self) -> Generator[NodeEventBase, None, None]:
|
||||
def _run(self) -> Generator[NodeEventBase | NodeRunPauseRequestedEvent, None, None]:
|
||||
inputs: dict[str, Any] = {}
|
||||
process_data: dict[str, Any] = {}
|
||||
metadata: dict[str, Any] = {
|
||||
"agent_backend": {
|
||||
"status": "not_started",
|
||||
}
|
||||
}
|
||||
try:
|
||||
yield from self._run_inner(inputs=inputs, process_data=process_data, metadata=metadata)
|
||||
except Exception as error:
|
||||
if not process_data:
|
||||
raise
|
||||
yield self._failure_event(
|
||||
inputs=inputs,
|
||||
process_data=process_data,
|
||||
metadata=metadata,
|
||||
error=str(error),
|
||||
error_type="agent_workflow_node_runtime_error",
|
||||
)
|
||||
|
||||
def _run_inner(
|
||||
self,
|
||||
*,
|
||||
inputs: dict[str, Any],
|
||||
process_data: dict[str, Any],
|
||||
metadata: dict[str, Any],
|
||||
) -> Generator[NodeEventBase | NodeRunPauseRequestedEvent, None, None]:
|
||||
dify_ctx = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY))
|
||||
workflow_id = self.graph_init_params.workflow_id
|
||||
workflow_run_id = get_system_text(
|
||||
@@ -143,21 +168,24 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
self.graph_runtime_state.variable_pool,
|
||||
SystemVariableKey.CONVERSATION_ID,
|
||||
)
|
||||
inputs: dict[str, Any] = {}
|
||||
process_data: dict[str, Any] = {}
|
||||
metadata: dict[str, Any] = {
|
||||
"agent_backend": {
|
||||
"status": "not_started",
|
||||
}
|
||||
}
|
||||
|
||||
# ──── Setup: resolve binding once + extract declared outputs for stage 4 checks ────
|
||||
try:
|
||||
existing_scope = self._session_store.load_existing_node_execution_scope(
|
||||
tenant_id=dify_ctx.tenant_id,
|
||||
app_id=dify_ctx.app_id,
|
||||
workflow_id=workflow_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
node_id=self._node_id,
|
||||
node_execution_id=self.execution_id,
|
||||
)
|
||||
bundle = self._binding_resolver.resolve(
|
||||
tenant_id=dify_ctx.tenant_id,
|
||||
app_id=dify_ctx.app_id,
|
||||
workflow_id=workflow_id,
|
||||
node_id=self._node_id,
|
||||
binding_id=existing_scope.workflow_agent_binding_id if existing_scope is not None else None,
|
||||
snapshot_id=existing_scope.agent_config_snapshot_id if existing_scope is not None else None,
|
||||
)
|
||||
except WorkflowAgentBindingError as error:
|
||||
yield self._failure_event(
|
||||
@@ -168,20 +196,31 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
error_type=error.error_code,
|
||||
)
|
||||
return
|
||||
except AgentWorkspaceNotFoundError as error:
|
||||
yield self._failure_event(
|
||||
inputs=inputs,
|
||||
process_data=process_data,
|
||||
metadata=metadata,
|
||||
error=str(error),
|
||||
error_type="agent_workflow_node_runtime_error",
|
||||
)
|
||||
return
|
||||
|
||||
process_data = {
|
||||
"agent_id": bundle.agent.id,
|
||||
"agent_config_snapshot_id": bundle.snapshot.id,
|
||||
"binding_id": bundle.binding.id,
|
||||
}
|
||||
session_scope = WorkflowAgentSessionScope(
|
||||
process_data.update(
|
||||
{
|
||||
"agent_id": bundle.agent.id,
|
||||
"agent_config_snapshot_id": bundle.snapshot.id,
|
||||
"workflow_agent_binding_id": bundle.binding.id,
|
||||
}
|
||||
)
|
||||
session_scope = existing_scope or WorkflowAgentSessionScope(
|
||||
tenant_id=dify_ctx.tenant_id,
|
||||
app_id=dify_ctx.app_id,
|
||||
workflow_id=workflow_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
node_id=self._node_id,
|
||||
node_execution_id=self.id,
|
||||
binding_id=bundle.binding.id,
|
||||
node_execution_id=self.execution_id,
|
||||
workflow_agent_binding_id=bundle.binding.id,
|
||||
agent_id=bundle.agent.id,
|
||||
agent_config_snapshot_id=bundle.snapshot.id,
|
||||
)
|
||||
@@ -200,47 +239,53 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
# the second Agent run as deferred_tool_results; if it is somehow still
|
||||
# waiting, re-emit the same pause defensively.
|
||||
deferred_tool_results = None
|
||||
if self._session_store is not None:
|
||||
stored_session = self._session_store.load_active_session(session_scope)
|
||||
if stored_session is not None and stored_session.pending_form_id is not None:
|
||||
resume_outcome = resolve_ask_human_form(
|
||||
form_id=stored_session.pending_form_id,
|
||||
tenant_id=dify_ctx.tenant_id,
|
||||
node_id=self._node_id,
|
||||
stored_session = self._session_store.load_or_create_node_execution_session(
|
||||
session_scope,
|
||||
home_snapshot_id=bundle.snapshot.home_snapshot_id,
|
||||
)
|
||||
if stored_session.pending_form_id is not None:
|
||||
resume_outcome = resolve_ask_human_form(
|
||||
form_id=stored_session.pending_form_id,
|
||||
tenant_id=dify_ctx.tenant_id,
|
||||
node_id=self._node_id,
|
||||
)
|
||||
if resume_outcome is not None and resume_outcome.repause is not None:
|
||||
yield self._pause_event(
|
||||
reason=resume_outcome.repause,
|
||||
inputs=inputs,
|
||||
process_data=process_data,
|
||||
metadata=metadata,
|
||||
)
|
||||
return
|
||||
if (
|
||||
resume_outcome is not None
|
||||
and resume_outcome.deferred_result is not None
|
||||
and stored_session.pending_tool_call_id is not None
|
||||
):
|
||||
deferred_tool_results = build_deferred_tool_results(
|
||||
tool_call_id=stored_session.pending_tool_call_id,
|
||||
result=resume_outcome.deferred_result,
|
||||
)
|
||||
if resume_outcome is not None and resume_outcome.repause is not None:
|
||||
yield PauseRequestedEvent(reason=self._to_graph_pause_reason(resume_outcome.repause))
|
||||
return
|
||||
if (
|
||||
resume_outcome is not None
|
||||
and resume_outcome.deferred_result is not None
|
||||
and stored_session.pending_tool_call_id is not None
|
||||
):
|
||||
deferred_tool_results = build_deferred_tool_results(
|
||||
tool_call_id=stored_session.pending_tool_call_id,
|
||||
result=resume_outcome.deferred_result,
|
||||
)
|
||||
|
||||
# ──── Retry loop (Stage 4 §7) ────
|
||||
attempt = 0
|
||||
while True:
|
||||
try:
|
||||
session_snapshot = None
|
||||
if self._session_store is not None:
|
||||
session_snapshot = self._session_store.load_active_snapshot(session_scope)
|
||||
runtime_request = self._runtime_request_builder.build(
|
||||
WorkflowAgentRuntimeBuildContext(
|
||||
dify_context=dify_ctx,
|
||||
workflow_id=workflow_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
node_id=self._node_id,
|
||||
node_execution_id=self.id,
|
||||
node_execution_id=self.execution_id,
|
||||
variable_pool=self.graph_runtime_state.variable_pool,
|
||||
binding=bundle.binding,
|
||||
agent=bundle.agent,
|
||||
snapshot=bundle.snapshot,
|
||||
binding_id=stored_session.binding_id,
|
||||
backend_binding_ref=stored_session.backend_binding_ref,
|
||||
attempt=attempt,
|
||||
session_snapshot=session_snapshot,
|
||||
session_snapshot=stored_session.session_snapshot,
|
||||
deferred_tool_results=deferred_tool_results,
|
||||
)
|
||||
)
|
||||
@@ -266,8 +311,9 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
# Capture inputs only from the first attempt so retry doesn't churn the
|
||||
# node's "inputs" payload that ends up in the workflow detail view.
|
||||
if attempt == 0:
|
||||
inputs = {"agent_backend_request": runtime_request.redacted_request}
|
||||
metadata = dict(runtime_request.metadata)
|
||||
inputs["agent_backend_request"] = runtime_request.redacted_request
|
||||
metadata.clear()
|
||||
metadata.update(runtime_request.metadata)
|
||||
metadata["attempt"] = attempt
|
||||
|
||||
try:
|
||||
@@ -288,7 +334,12 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
"status": create_response.status,
|
||||
}
|
||||
|
||||
terminal_event, exhausted = self._consume_event_stream(create_response.run_id, metadata)
|
||||
terminal_event, exhausted = self._consume_event_stream(
|
||||
create_response.run_id,
|
||||
inputs=inputs,
|
||||
process_data=process_data,
|
||||
metadata=metadata,
|
||||
)
|
||||
if exhausted is not None:
|
||||
# Streaming error / unexpected end — surface immediately without
|
||||
# retrying because the failure is transport-level.
|
||||
@@ -349,29 +400,23 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
)
|
||||
self._save_session_snapshot(
|
||||
session_scope=session_scope,
|
||||
backend_run_id=terminal_event.run_id,
|
||||
binding_id=stored_session.binding_id,
|
||||
snapshot=terminal_event.session_snapshot,
|
||||
runtime_layer_specs=extract_runtime_layer_specs(runtime_request.request.composition),
|
||||
metadata=metadata,
|
||||
pending_form_id=pending_form_id,
|
||||
pending_tool_call_id=pending_tool_call_id,
|
||||
)
|
||||
yield PauseRequestedEvent(reason=self._to_graph_pause_reason(pause_reason))
|
||||
return
|
||||
|
||||
# Non-success terminal (failed / cancelled) skips per-output
|
||||
# post-processing — the backend itself already failed. We also retire
|
||||
# the local ACTIVE session row so a workflow loop back into the same
|
||||
# Agent node cannot resume from a stale snapshot. The failed agent
|
||||
# backend layers (suspended per ``on_exit``) are left for agent
|
||||
# backend's own GC; this row will no longer be picked up by the
|
||||
# workflow-terminal cleanup layer.
|
||||
if not isinstance(terminal_event, AgentBackendRunSucceededInternalEvent):
|
||||
self._mark_session_cleaned_on_failure(
|
||||
session_scope=session_scope,
|
||||
backend_run_id=terminal_event.run_id,
|
||||
yield self._pause_event(
|
||||
reason=pause_reason,
|
||||
inputs=inputs,
|
||||
process_data=process_data,
|
||||
metadata=metadata,
|
||||
)
|
||||
return
|
||||
|
||||
# A failed attempt does not retire the product-owned Binding. The
|
||||
# Workflow Run terminal lifecycle event owns that transition.
|
||||
if not isinstance(terminal_event, AgentBackendRunSucceededInternalEvent):
|
||||
yield StreamCompletedEvent(
|
||||
node_run_result=self._output_adapter.build_failure_result(
|
||||
event=terminal_event,
|
||||
@@ -384,9 +429,8 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
|
||||
self._save_session_snapshot(
|
||||
session_scope=session_scope,
|
||||
backend_run_id=terminal_event.run_id,
|
||||
binding_id=stored_session.binding_id,
|
||||
snapshot=terminal_event.session_snapshot,
|
||||
runtime_layer_specs=extract_runtime_layer_specs(runtime_request.request.composition),
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
@@ -458,6 +502,9 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
def _consume_event_stream(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
inputs: dict[str, Any],
|
||||
process_data: dict[str, Any],
|
||||
metadata: dict[str, Any],
|
||||
) -> tuple[
|
||||
_TerminalAgentBackendEvent | None,
|
||||
@@ -507,8 +554,8 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
return internal_event, None
|
||||
self._cancel_backend_run(run_id, reason="unexpected_event")
|
||||
return None, self._failure_event(
|
||||
inputs={},
|
||||
process_data={},
|
||||
inputs=inputs,
|
||||
process_data=process_data,
|
||||
metadata=metadata,
|
||||
error=f"Unexpected internal event type {internal_event.type!r}",
|
||||
error_type="agent_backend_stream_error",
|
||||
@@ -516,8 +563,8 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
except AgentBackendError as error:
|
||||
self._cancel_backend_run(run_id, reason=self._stream_stop_reason())
|
||||
return None, self._failure_event(
|
||||
inputs={},
|
||||
process_data={},
|
||||
inputs=inputs,
|
||||
process_data=process_data,
|
||||
metadata=metadata,
|
||||
error=str(error),
|
||||
error_type=self._agent_backend_error_type(error),
|
||||
@@ -525,8 +572,8 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
except Exception as error:
|
||||
self._cancel_backend_run(run_id, reason=self._stream_stop_reason())
|
||||
return None, self._failure_event(
|
||||
inputs={},
|
||||
process_data={},
|
||||
inputs=inputs,
|
||||
process_data=process_data,
|
||||
metadata=metadata,
|
||||
error=str(error),
|
||||
error_type="agent_backend_stream_error",
|
||||
@@ -596,21 +643,17 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
self,
|
||||
*,
|
||||
session_scope: WorkflowAgentSessionScope,
|
||||
backend_run_id: str,
|
||||
binding_id: str,
|
||||
snapshot: CompositorSessionSnapshot | None,
|
||||
runtime_layer_specs: list[RuntimeLayerSpec],
|
||||
metadata: dict[str, Any],
|
||||
pending_form_id: str | None = None,
|
||||
pending_tool_call_id: str | None = None,
|
||||
) -> None:
|
||||
if self._session_store is None:
|
||||
return
|
||||
try:
|
||||
self._session_store.save_active_snapshot(
|
||||
scope=session_scope,
|
||||
backend_run_id=backend_run_id,
|
||||
binding_id=binding_id,
|
||||
snapshot=snapshot,
|
||||
runtime_layer_specs=runtime_layer_specs,
|
||||
pending_form_id=pending_form_id,
|
||||
pending_tool_call_id=pending_tool_call_id,
|
||||
)
|
||||
@@ -619,88 +662,18 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
metadata["agent_backend"] = agent_backend
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist workflow Agent runtime session snapshot: "
|
||||
"tenant_id=%s workflow_run_id=%s node_id=%s binding_id=%s agent_id=%s backend_run_id=%s",
|
||||
"Failed to persist workflow Agent Binding session snapshot: "
|
||||
"tenant_id=%s workflow_run_id=%s node_id=%s binding_id=%s agent_id=%s",
|
||||
session_scope.tenant_id,
|
||||
session_scope.workflow_run_id,
|
||||
session_scope.node_id,
|
||||
session_scope.binding_id,
|
||||
session_scope.workflow_agent_binding_id,
|
||||
session_scope.agent_id,
|
||||
backend_run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
agent_backend = dict(metadata.get("agent_backend") or {})
|
||||
agent_backend["session_snapshot_persisted"] = False
|
||||
agent_backend["session_snapshot_persist_error"] = "workflow_agent_runtime_session_store_error"
|
||||
metadata["agent_backend"] = agent_backend
|
||||
|
||||
def _mark_session_cleaned_on_failure(
|
||||
self,
|
||||
*,
|
||||
session_scope: WorkflowAgentSessionScope,
|
||||
backend_run_id: str,
|
||||
metadata: dict[str, Any],
|
||||
) -> None:
|
||||
if self._session_store is None:
|
||||
return
|
||||
stored_session = self._session_store.load_active_session(session_scope)
|
||||
try:
|
||||
if stored_session is not None and stored_session.runtime_layer_specs:
|
||||
payload = AgentBackendSessionCleanupPayload(
|
||||
session_snapshot=stored_session.session_snapshot,
|
||||
runtime_layer_specs=stored_session.runtime_layer_specs,
|
||||
idempotency_key=(
|
||||
f"{session_scope.tenant_id}:{session_scope.workflow_run_id}:{session_scope.node_id}:"
|
||||
f"{session_scope.binding_id}:workflow-agent-failure-cleanup:"
|
||||
f"{stored_session.backend_run_id or 'no-stored-run'}:{backend_run_id}"
|
||||
),
|
||||
metadata={
|
||||
"tenant_id": session_scope.tenant_id,
|
||||
"app_id": session_scope.app_id,
|
||||
"workflow_id": session_scope.workflow_id,
|
||||
"workflow_run_id": session_scope.workflow_run_id,
|
||||
"node_id": session_scope.node_id,
|
||||
"node_execution_id": session_scope.node_execution_id,
|
||||
"binding_id": session_scope.binding_id,
|
||||
"agent_id": session_scope.agent_id,
|
||||
"agent_config_snapshot_id": session_scope.agent_config_snapshot_id,
|
||||
"previous_agent_backend_run_id": stored_session.backend_run_id,
|
||||
"failed_agent_backend_run_id": backend_run_id,
|
||||
},
|
||||
)
|
||||
cleanup_workflow_agent_runtime_session.delay(payload.model_dump(mode="json"))
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to enqueue workflow Agent backend cleanup on agent run failure: "
|
||||
"tenant_id=%s workflow_run_id=%s node_id=%s binding_id=%s agent_id=%s backend_run_id=%s",
|
||||
session_scope.tenant_id,
|
||||
session_scope.workflow_run_id,
|
||||
session_scope.node_id,
|
||||
session_scope.binding_id,
|
||||
session_scope.agent_id,
|
||||
backend_run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
self._session_store.mark_cleaned(scope=session_scope, backend_run_id=backend_run_id)
|
||||
agent_backend = dict(metadata.get("agent_backend") or {})
|
||||
agent_backend["session_snapshot_cleaned_on_failure"] = True
|
||||
metadata["agent_backend"] = agent_backend
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to mark workflow Agent runtime session cleaned on agent run failure: "
|
||||
"tenant_id=%s workflow_run_id=%s node_id=%s binding_id=%s agent_id=%s backend_run_id=%s",
|
||||
session_scope.tenant_id,
|
||||
session_scope.workflow_run_id,
|
||||
session_scope.node_id,
|
||||
session_scope.binding_id,
|
||||
session_scope.agent_id,
|
||||
backend_run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
agent_backend = dict(metadata.get("agent_backend") or {})
|
||||
agent_backend["session_snapshot_cleaned_on_failure"] = False
|
||||
agent_backend["session_snapshot_cleanup_error"] = "workflow_agent_runtime_session_store_error"
|
||||
agent_backend["session_snapshot_persist_error"] = "workflow_agent_workspace_store_error"
|
||||
metadata["agent_backend"] = agent_backend
|
||||
|
||||
@staticmethod
|
||||
@@ -742,6 +715,27 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
)
|
||||
)
|
||||
|
||||
def _pause_event(
|
||||
self,
|
||||
*,
|
||||
reason: HumanInputRequired | SchedulingPause,
|
||||
inputs: dict[str, Any],
|
||||
process_data: dict[str, Any],
|
||||
metadata: dict[str, Any],
|
||||
) -> NodeRunPauseRequestedEvent:
|
||||
return NodeRunPauseRequestedEvent(
|
||||
id=self.execution_id,
|
||||
node_id=self._node_id,
|
||||
node_type=self.node_type,
|
||||
node_run_result=NodeRunResult(
|
||||
status=WorkflowNodeExecutionStatus.PAUSED,
|
||||
inputs=inputs,
|
||||
process_data=process_data,
|
||||
metadata={WorkflowNodeExecutionMetadataKey.AGENT_LOG: metadata},
|
||||
),
|
||||
reason=self._to_graph_pause_reason(reason),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _agent_backend_error_type(error: AgentBackendError) -> str:
|
||||
if isinstance(error, AgentBackendValidationError):
|
||||
|
||||
@@ -41,18 +41,27 @@ class WorkflowAgentBindingResolver:
|
||||
app_id: str,
|
||||
workflow_id: str,
|
||||
node_id: str,
|
||||
binding_id: str | None = None,
|
||||
snapshot_id: str | None = None,
|
||||
) -> WorkflowAgentBindingBundle:
|
||||
with session_factory.create_session() as session:
|
||||
binding = session.scalar(
|
||||
select(WorkflowAgentNodeBinding)
|
||||
.where(
|
||||
WorkflowAgentNodeBinding.tenant_id == tenant_id,
|
||||
WorkflowAgentNodeBinding.app_id == app_id,
|
||||
WorkflowAgentNodeBinding.workflow_id == workflow_id,
|
||||
WorkflowAgentNodeBinding.node_id == node_id,
|
||||
)
|
||||
.limit(1)
|
||||
"""Resolve the current binding, optionally at a generation pinned by an existing execution."""
|
||||
|
||||
if (binding_id is None) != (snapshot_id is None):
|
||||
raise WorkflowAgentBindingError(
|
||||
"agent_binding_generation_invalid",
|
||||
"Workflow Agent binding and config snapshot must be pinned together.",
|
||||
)
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
binding_stmt = select(WorkflowAgentNodeBinding).where(
|
||||
WorkflowAgentNodeBinding.tenant_id == tenant_id,
|
||||
WorkflowAgentNodeBinding.app_id == app_id,
|
||||
WorkflowAgentNodeBinding.workflow_id == workflow_id,
|
||||
WorkflowAgentNodeBinding.node_id == node_id,
|
||||
)
|
||||
if binding_id is not None:
|
||||
binding_stmt = binding_stmt.where(WorkflowAgentNodeBinding.id == binding_id)
|
||||
binding = session.scalar(binding_stmt.limit(1))
|
||||
if binding is None:
|
||||
raise WorkflowAgentBindingError(
|
||||
"agent_binding_not_found",
|
||||
@@ -77,12 +86,16 @@ class WorkflowAgentBindingResolver:
|
||||
f"Agent {binding.agent_id} is not available or has not been published.",
|
||||
)
|
||||
|
||||
snapshot_id = (
|
||||
agent.active_config_snapshot_id
|
||||
if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
|
||||
else binding.current_snapshot_id
|
||||
effective_snapshot_id = (
|
||||
(
|
||||
agent.active_config_snapshot_id
|
||||
if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
|
||||
else binding.current_snapshot_id
|
||||
)
|
||||
if snapshot_id is None
|
||||
else snapshot_id
|
||||
)
|
||||
if snapshot_id is None:
|
||||
if effective_snapshot_id is None:
|
||||
raise WorkflowAgentBindingError(
|
||||
"agent_config_snapshot_not_found",
|
||||
"Workflow Agent binding has no current config snapshot.",
|
||||
@@ -93,14 +106,14 @@ class WorkflowAgentBindingResolver:
|
||||
.where(
|
||||
AgentConfigSnapshot.tenant_id == tenant_id,
|
||||
AgentConfigSnapshot.agent_id == agent.id,
|
||||
AgentConfigSnapshot.id == snapshot_id,
|
||||
AgentConfigSnapshot.id == effective_snapshot_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if snapshot is None:
|
||||
raise WorkflowAgentBindingError(
|
||||
"agent_config_snapshot_not_found",
|
||||
f"Agent config snapshot {snapshot_id} not found.",
|
||||
f"Agent config snapshot {effective_snapshot_id} not found.",
|
||||
)
|
||||
|
||||
session.expunge(binding)
|
||||
|
||||
@@ -33,7 +33,6 @@ from dify_agent.layers.shell import (
|
||||
DifyShellCliToolConfig,
|
||||
DifyShellEnvVarConfig,
|
||||
DifyShellLayerConfig,
|
||||
DifyShellSandboxConfig,
|
||||
DifyShellSecretRefConfig,
|
||||
)
|
||||
from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload
|
||||
@@ -136,6 +135,8 @@ class WorkflowAgentRuntimeBuildContext:
|
||||
binding: WorkflowAgentNodeBinding
|
||||
agent: Agent
|
||||
snapshot: AgentConfigSnapshot
|
||||
binding_id: str
|
||||
backend_binding_ref: str
|
||||
# Stage 4 §7 / D-4: 0 for the first run, then incremented per retry. Drives the
|
||||
# idempotency key so the backend treats each retry as a fresh request.
|
||||
attempt: int = 0
|
||||
@@ -251,6 +252,7 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
agent_mode=self._agent_backend_agent_mode(context.dify_context.invoke_from),
|
||||
invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value),
|
||||
),
|
||||
backend_binding_ref=context.backend_binding_ref,
|
||||
agent_soul_prompt=soul_prompt or None,
|
||||
workflow_node_job_prompt=workflow_job_prompt,
|
||||
user_prompt=user_prompt,
|
||||
@@ -739,7 +741,6 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
|
||||
def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfig:
|
||||
"""Map Agent Soul shell-adjacent fields into the Agent backend shell config."""
|
||||
sandbox_config = _plain_mapping(agent_soul.sandbox.config)
|
||||
return DifyShellLayerConfig(
|
||||
cli_tools=[
|
||||
tool
|
||||
@@ -750,12 +751,6 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi
|
||||
secret_refs=[
|
||||
secret for secret in (_shell_secret_ref(item) for item in agent_soul.env.secret_refs) if secret is not None
|
||||
],
|
||||
sandbox=DifyShellSandboxConfig(
|
||||
provider=agent_soul.sandbox.provider,
|
||||
config=sandbox_config,
|
||||
)
|
||||
if agent_soul.sandbox.provider or sandbox_config
|
||||
else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
"""Workflow terminal layer that retires Agent backend sessions asynchronously."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import override
|
||||
|
||||
from clients.agent_backend import AgentBackendSessionCleanupPayload
|
||||
from core.workflow.system_variables import SystemVariableKey, get_system_text
|
||||
from graphon.graph_engine.layers import GraphEngineLayer
|
||||
from graphon.graph_events import (
|
||||
GraphEngineEvent,
|
||||
GraphRunAbortedEvent,
|
||||
GraphRunFailedEvent,
|
||||
GraphRunPartialSucceededEvent,
|
||||
GraphRunSucceededEvent,
|
||||
)
|
||||
from tasks.agent_backend_session_cleanup_task import cleanup_workflow_agent_runtime_session
|
||||
|
||||
from .session_store import StoredWorkflowAgentSession, WorkflowAgentRuntimeSessionStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkflowAgentSessionCleanupLayer(GraphEngineLayer):
|
||||
"""Retire workflow-owned Agent runtime sessions when the workflow ends.
|
||||
|
||||
Workflow termination is a product-lifecycle boundary: once the run reaches a
|
||||
terminal graph event, the local session row must no longer be resumable. The
|
||||
actual Agent backend cleanup is therefore dispatched asynchronously with the
|
||||
persisted snapshot/specs payload, while the local row is marked CLEANED
|
||||
immediately afterwards regardless of enqueue outcome.
|
||||
"""
|
||||
|
||||
_TERMINAL_EVENTS = (
|
||||
GraphRunSucceededEvent,
|
||||
GraphRunPartialSucceededEvent,
|
||||
GraphRunFailedEvent,
|
||||
GraphRunAbortedEvent,
|
||||
)
|
||||
|
||||
def __init__(self, *, session_store: WorkflowAgentRuntimeSessionStore) -> None:
|
||||
super().__init__()
|
||||
self._session_store = session_store
|
||||
|
||||
@override
|
||||
def on_graph_start(self) -> None:
|
||||
return
|
||||
|
||||
@override
|
||||
def on_event(self, event: GraphEngineEvent) -> None:
|
||||
if not isinstance(event, self._TERMINAL_EVENTS):
|
||||
return
|
||||
workflow_run_id = get_system_text(
|
||||
self.graph_runtime_state.variable_pool,
|
||||
SystemVariableKey.WORKFLOW_EXECUTION_ID,
|
||||
)
|
||||
if not workflow_run_id:
|
||||
logger.warning("Skipping workflow Agent session cleanup: workflow_run_id is missing.")
|
||||
return
|
||||
|
||||
for stored_session in self._session_store.list_active_sessions(workflow_run_id=workflow_run_id):
|
||||
self._cleanup_session(stored_session)
|
||||
|
||||
@override
|
||||
def on_graph_end(self, error: Exception | None) -> None:
|
||||
return
|
||||
|
||||
def _cleanup_session(self, stored_session: StoredWorkflowAgentSession) -> None:
|
||||
scope = stored_session.scope
|
||||
try:
|
||||
if stored_session.runtime_layer_specs:
|
||||
payload = AgentBackendSessionCleanupPayload(
|
||||
session_snapshot=stored_session.session_snapshot,
|
||||
runtime_layer_specs=stored_session.runtime_layer_specs,
|
||||
idempotency_key=f"{scope.workflow_run_id}:{scope.node_id}:{scope.binding_id}:agent-session-cleanup",
|
||||
metadata={
|
||||
"tenant_id": scope.tenant_id,
|
||||
"app_id": scope.app_id,
|
||||
"workflow_id": scope.workflow_id,
|
||||
"workflow_run_id": scope.workflow_run_id,
|
||||
"node_id": scope.node_id,
|
||||
"node_execution_id": scope.node_execution_id,
|
||||
"binding_id": scope.binding_id,
|
||||
"agent_id": scope.agent_id,
|
||||
"agent_config_snapshot_id": scope.agent_config_snapshot_id,
|
||||
"previous_agent_backend_run_id": stored_session.backend_run_id,
|
||||
},
|
||||
)
|
||||
cleanup_workflow_agent_runtime_session.delay(payload.model_dump(mode="json"))
|
||||
else:
|
||||
logger.warning(
|
||||
"Skipping workflow Agent backend cleanup enqueue: no runtime_layer_specs persisted. "
|
||||
"workflow_run_id=%s node_id=%s agent_id=%s",
|
||||
scope.workflow_run_id,
|
||||
scope.node_id,
|
||||
scope.agent_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to enqueue workflow Agent backend cleanup: "
|
||||
"workflow_run_id=%s node_id=%s agent_id=%s previous_run_id=%s",
|
||||
scope.workflow_run_id,
|
||||
scope.node_id,
|
||||
scope.agent_id,
|
||||
stored_session.backend_run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
self._session_store.mark_cleaned(scope=scope, backend_run_id=stored_session.backend_run_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to retire workflow Agent runtime session after cleanup enqueue: "
|
||||
"workflow_run_id=%s node_id=%s agent_id=%s previous_run_id=%s",
|
||||
scope.workflow_run_id,
|
||||
scope.node_id,
|
||||
scope.agent_id,
|
||||
stored_session.backend_run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def build_workflow_agent_session_cleanup_layer() -> WorkflowAgentSessionCleanupLayer:
|
||||
"""Wire the cleanup layer with the standard workflow-owned session store."""
|
||||
return WorkflowAgentSessionCleanupLayer(session_store=WorkflowAgentRuntimeSessionStore())
|
||||
@@ -1,31 +1,32 @@
|
||||
"""Workflow Agent participant persistence keyed by node execution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from dify_agent.protocol import RuntimeLayerSpec
|
||||
from pydantic import TypeAdapter
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.db.session_factory import session_factory
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.agent import (
|
||||
AgentRuntimeSessionOwnerType,
|
||||
WorkflowAgentRuntimeSession,
|
||||
WorkflowAgentRuntimeSessionStatus,
|
||||
AgentConfigVersionKind,
|
||||
AgentWorkingResourceStatus,
|
||||
AgentWorkspace,
|
||||
AgentWorkspaceBinding,
|
||||
AgentWorkspaceOwnerType,
|
||||
)
|
||||
from models.workflow import WorkflowNodeExecutionModel
|
||||
from services.agent.workspace_service import (
|
||||
AgentWorkspaceNotFoundError,
|
||||
AgentWorkspaceService,
|
||||
WorkspaceOwnerScope,
|
||||
)
|
||||
|
||||
_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec])
|
||||
|
||||
|
||||
def _serialize_specs(specs: list[RuntimeLayerSpec]) -> str:
|
||||
return _SPECS_ADAPTER.dump_json(specs).decode()
|
||||
|
||||
|
||||
def _deserialize_specs(value: str | None) -> list[RuntimeLayerSpec]:
|
||||
if not value:
|
||||
return []
|
||||
return _SPECS_ADAPTER.validate_json(value)
|
||||
_CALLER_VISIBILITY_ATTEMPTS = 60
|
||||
_CALLER_VISIBILITY_INTERVAL_SECONDS = 0.05
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -36,171 +37,251 @@ class WorkflowAgentSessionScope:
|
||||
workflow_run_id: str | None
|
||||
node_id: str
|
||||
node_execution_id: str
|
||||
binding_id: str
|
||||
workflow_agent_binding_id: str
|
||||
agent_id: str
|
||||
agent_config_snapshot_id: str
|
||||
|
||||
@property
|
||||
def workspace_owner(self) -> WorkspaceOwnerScope:
|
||||
return WorkspaceOwnerScope(
|
||||
tenant_id=self.tenant_id,
|
||||
app_id=self.app_id,
|
||||
owner_type=AgentWorkspaceOwnerType.WORKFLOW_RUN,
|
||||
owner_id=self.workflow_run_id or self.node_execution_id,
|
||||
owner_scope_key=f"{self.node_id}:{self.workflow_agent_binding_id}",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StoredWorkflowAgentSession:
|
||||
scope: WorkflowAgentSessionScope
|
||||
session_snapshot: CompositorSessionSnapshot
|
||||
backend_run_id: str | None
|
||||
runtime_layer_specs: list[RuntimeLayerSpec] = field(default_factory=list)
|
||||
# ENG-637: set while the session is paused on a dify.ask_human deferred call.
|
||||
binding_id: str
|
||||
workspace_id: str
|
||||
backend_binding_ref: str
|
||||
session_snapshot: CompositorSessionSnapshot | None
|
||||
pending_form_id: str | None = None
|
||||
pending_tool_call_id: str | None = None
|
||||
|
||||
|
||||
class WorkflowAgentRuntimeSessionStore:
|
||||
"""Stores Agent backend session snapshots for workflow Agent node re-entry."""
|
||||
class WorkflowAgentWorkspaceStore:
|
||||
"""Load or create the participant named by a node execution caller row."""
|
||||
|
||||
def load_active_snapshot(self, scope: WorkflowAgentSessionScope) -> CompositorSessionSnapshot | None:
|
||||
stored = self.load_active_session(scope)
|
||||
return stored.session_snapshot if stored is not None else None
|
||||
|
||||
def load_active_session(self, scope: WorkflowAgentSessionScope) -> StoredWorkflowAgentSession | None:
|
||||
"""Load the active session row including any pending ask_human correlation."""
|
||||
if scope.workflow_run_id is None:
|
||||
return None
|
||||
def load_existing_node_execution_scope(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
app_id: str,
|
||||
workflow_id: str,
|
||||
workflow_run_id: str | None,
|
||||
node_id: str,
|
||||
node_execution_id: str,
|
||||
) -> WorkflowAgentSessionScope | None:
|
||||
"""Return the generation pinned by an existing node execution participant."""
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
row = session.scalar(
|
||||
select(WorkflowAgentRuntimeSession).where(
|
||||
WorkflowAgentRuntimeSession.tenant_id == scope.tenant_id,
|
||||
WorkflowAgentRuntimeSession.workflow_run_id == scope.workflow_run_id,
|
||||
WorkflowAgentRuntimeSession.node_id == scope.node_id,
|
||||
WorkflowAgentRuntimeSession.binding_id == scope.binding_id,
|
||||
WorkflowAgentRuntimeSession.agent_id == scope.agent_id,
|
||||
WorkflowAgentRuntimeSession.status == WorkflowAgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
execution = self._load_execution_by_identity(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
workflow_id=workflow_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
node_id=node_id,
|
||||
node_execution_id=node_execution_id,
|
||||
)
|
||||
if row is None:
|
||||
binding_id = execution.agent_workspace_binding_id
|
||||
if binding_id is None:
|
||||
return None
|
||||
return StoredWorkflowAgentSession(
|
||||
scope=scope,
|
||||
session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot),
|
||||
backend_run_id=row.backend_run_id,
|
||||
runtime_layer_specs=_deserialize_specs(row.composition_layer_specs),
|
||||
pending_form_id=row.pending_form_id,
|
||||
pending_tool_call_id=row.pending_tool_call_id,
|
||||
process_data = execution.process_data_dict
|
||||
if not isinstance(process_data, dict):
|
||||
raise AgentWorkspaceNotFoundError("Workflow node execution caller identity is invalid")
|
||||
workflow_agent_binding_id = process_data.get("workflow_agent_binding_id")
|
||||
if not isinstance(workflow_agent_binding_id, str):
|
||||
raise AgentWorkspaceNotFoundError("Workflow node execution caller identity is missing")
|
||||
owner_scope = WorkspaceOwnerScope(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
owner_type=AgentWorkspaceOwnerType.WORKFLOW_RUN,
|
||||
owner_id=workflow_run_id or node_execution_id,
|
||||
owner_scope_key=f"{node_id}:{workflow_agent_binding_id}",
|
||||
)
|
||||
binding = AgentWorkspaceService.get_active_binding(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
binding_id=binding_id,
|
||||
expected_owner_scope=owner_scope,
|
||||
)
|
||||
if binding is None or binding.agent_config_version_kind != AgentConfigVersionKind.SNAPSHOT:
|
||||
raise AgentWorkspaceNotFoundError("Workflow node participant Binding is unavailable")
|
||||
return WorkflowAgentSessionScope(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
workflow_id=workflow_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
node_id=node_id,
|
||||
node_execution_id=node_execution_id,
|
||||
workflow_agent_binding_id=workflow_agent_binding_id,
|
||||
agent_id=binding.agent_id,
|
||||
agent_config_snapshot_id=binding.agent_config_version_id,
|
||||
)
|
||||
|
||||
def list_active_sessions(self, *, workflow_run_id: str) -> list[StoredWorkflowAgentSession]:
|
||||
def load_or_create_node_execution_session(
|
||||
self, scope: WorkflowAgentSessionScope, *, home_snapshot_id: str
|
||||
) -> StoredWorkflowAgentSession:
|
||||
with session_factory.create_session() as session:
|
||||
rows = session.scalars(
|
||||
select(WorkflowAgentRuntimeSession).where(
|
||||
WorkflowAgentRuntimeSession.workflow_run_id == workflow_run_id,
|
||||
WorkflowAgentRuntimeSession.status == WorkflowAgentRuntimeSessionStatus.ACTIVE,
|
||||
execution = self._load_execution(session=session, scope=scope)
|
||||
process_data = execution.process_data_dict
|
||||
if process_data is None:
|
||||
process_data = {}
|
||||
if not isinstance(process_data, dict):
|
||||
raise AgentWorkspaceNotFoundError("Workflow node execution caller identity is invalid")
|
||||
stored_workflow_binding_id = process_data.get("workflow_agent_binding_id")
|
||||
if stored_workflow_binding_id is not None and stored_workflow_binding_id != scope.workflow_agent_binding_id:
|
||||
raise AgentWorkspaceNotFoundError("Workflow node execution caller identity does not match")
|
||||
|
||||
binding_id = execution.agent_workspace_binding_id
|
||||
if binding_id is None:
|
||||
binding = AgentWorkspaceService.create_binding(
|
||||
session=session,
|
||||
scope=scope.workspace_owner,
|
||||
agent_id=scope.agent_id,
|
||||
base_home_snapshot_id=home_snapshot_id,
|
||||
agent_config_version_id=scope.agent_config_snapshot_id,
|
||||
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
|
||||
)
|
||||
).all()
|
||||
return [
|
||||
StoredWorkflowAgentSession(
|
||||
scope=WorkflowAgentSessionScope(
|
||||
tenant_id=row.tenant_id,
|
||||
app_id=row.app_id,
|
||||
# These columns are nullable on the unified runtime-session
|
||||
# table (workflow_run ⊕ conversation owner), but are always
|
||||
# populated for a workflow-owned row; coerce for the typed scope.
|
||||
workflow_id=row.workflow_id or "",
|
||||
workflow_run_id=row.workflow_run_id,
|
||||
node_id=row.node_id or "",
|
||||
node_execution_id=row.node_execution_id or "",
|
||||
binding_id=row.binding_id or "",
|
||||
agent_id=row.agent_id,
|
||||
agent_config_snapshot_id=row.agent_config_snapshot_id or "",
|
||||
),
|
||||
session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot),
|
||||
backend_run_id=row.backend_run_id,
|
||||
runtime_layer_specs=_deserialize_specs(row.composition_layer_specs),
|
||||
execution.agent_workspace_binding_id = binding.id
|
||||
execution.process_data = json.dumps(
|
||||
{
|
||||
**process_data,
|
||||
"workflow_agent_binding_id": scope.workflow_agent_binding_id,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
session.commit()
|
||||
else:
|
||||
if stored_workflow_binding_id is None:
|
||||
raise AgentWorkspaceNotFoundError("Workflow node execution caller identity is missing")
|
||||
resolved_binding = AgentWorkspaceService.get_active_binding(
|
||||
session=session,
|
||||
tenant_id=scope.tenant_id,
|
||||
binding_id=binding_id,
|
||||
expected_owner_scope=scope.workspace_owner,
|
||||
)
|
||||
if resolved_binding is None or resolved_binding.agent_id != scope.agent_id:
|
||||
raise AgentWorkspaceNotFoundError("Workflow node participant Binding is unavailable")
|
||||
binding = resolved_binding
|
||||
AgentWorkspaceService.validate_binding_generation(
|
||||
binding,
|
||||
base_home_snapshot_id=home_snapshot_id,
|
||||
agent_config_version_id=scope.agent_config_snapshot_id,
|
||||
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
|
||||
)
|
||||
return self._stored(scope, binding)
|
||||
|
||||
def save_active_snapshot(
|
||||
self,
|
||||
*,
|
||||
scope: WorkflowAgentSessionScope,
|
||||
backend_run_id: str,
|
||||
binding_id: str,
|
||||
snapshot: CompositorSessionSnapshot | None,
|
||||
runtime_layer_specs: list[RuntimeLayerSpec],
|
||||
pending_form_id: str | None = None,
|
||||
pending_tool_call_id: str | None = None,
|
||||
) -> None:
|
||||
if scope.workflow_run_id is None or snapshot is None:
|
||||
if snapshot is None:
|
||||
return
|
||||
AgentWorkspaceService.save_binding_session_snapshot(
|
||||
tenant_id=scope.tenant_id,
|
||||
binding_id=binding_id,
|
||||
session_snapshot=snapshot.model_dump_json(),
|
||||
pending_form_id=pending_form_id,
|
||||
pending_tool_call_id=pending_tool_call_id,
|
||||
)
|
||||
|
||||
snapshot_json = snapshot.model_dump_json()
|
||||
specs_json = _serialize_specs(runtime_layer_specs)
|
||||
def retire_workflow_run(self, *, tenant_id: str, app_id: str, workflow_run_id: str) -> list[str]:
|
||||
"""Retire active Workspaces, commit, and return active or already-retired IDs for collection."""
|
||||
|
||||
retired: list[str] = []
|
||||
with session_factory.create_session() as session:
|
||||
row = session.scalar(
|
||||
select(WorkflowAgentRuntimeSession).where(
|
||||
WorkflowAgentRuntimeSession.tenant_id == scope.tenant_id,
|
||||
WorkflowAgentRuntimeSession.workflow_run_id == scope.workflow_run_id,
|
||||
WorkflowAgentRuntimeSession.node_id == scope.node_id,
|
||||
WorkflowAgentRuntimeSession.binding_id == scope.binding_id,
|
||||
WorkflowAgentRuntimeSession.agent_id == scope.agent_id,
|
||||
workspaces = session.scalars(
|
||||
select(AgentWorkspace).where(
|
||||
AgentWorkspace.tenant_id == tenant_id,
|
||||
AgentWorkspace.app_id == app_id,
|
||||
AgentWorkspace.owner_type == AgentWorkspaceOwnerType.WORKFLOW_RUN,
|
||||
AgentWorkspace.owner_id == workflow_run_id,
|
||||
AgentWorkspace.status.in_((AgentWorkingResourceStatus.ACTIVE, AgentWorkingResourceStatus.RETIRED)),
|
||||
)
|
||||
)
|
||||
if row is None:
|
||||
row = WorkflowAgentRuntimeSession(
|
||||
tenant_id=scope.tenant_id,
|
||||
app_id=scope.app_id,
|
||||
owner_type=AgentRuntimeSessionOwnerType.WORKFLOW_RUN,
|
||||
workflow_id=scope.workflow_id,
|
||||
workflow_run_id=scope.workflow_run_id,
|
||||
node_id=scope.node_id,
|
||||
node_execution_id=scope.node_execution_id,
|
||||
binding_id=scope.binding_id,
|
||||
agent_id=scope.agent_id,
|
||||
agent_config_snapshot_id=scope.agent_config_snapshot_id,
|
||||
backend_run_id=backend_run_id,
|
||||
session_snapshot=snapshot_json,
|
||||
composition_layer_specs=specs_json,
|
||||
status=WorkflowAgentRuntimeSessionStatus.ACTIVE,
|
||||
pending_form_id=pending_form_id,
|
||||
pending_tool_call_id=pending_tool_call_id,
|
||||
).all()
|
||||
for workspace in workspaces:
|
||||
if workspace.status == AgentWorkingResourceStatus.RETIRED:
|
||||
retired.append(workspace.id)
|
||||
continue
|
||||
workspace_id = AgentWorkspaceService.retire_workspace(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
workspace_id=workspace.id,
|
||||
)
|
||||
session.add(row)
|
||||
else:
|
||||
row.node_execution_id = scope.node_execution_id
|
||||
row.agent_config_snapshot_id = scope.agent_config_snapshot_id
|
||||
row.backend_run_id = backend_run_id
|
||||
row.session_snapshot = snapshot_json
|
||||
row.composition_layer_specs = specs_json
|
||||
row.status = WorkflowAgentRuntimeSessionStatus.ACTIVE
|
||||
row.cleaned_at = None
|
||||
# Set (or clear, when omitted) the ask_human pause correlation.
|
||||
row.pending_form_id = pending_form_id
|
||||
row.pending_tool_call_id = pending_tool_call_id
|
||||
if workspace_id is not None:
|
||||
retired.append(workspace_id)
|
||||
session.commit()
|
||||
return retired
|
||||
|
||||
def mark_cleaned(self, *, scope: WorkflowAgentSessionScope, backend_run_id: str | None = None) -> None:
|
||||
if scope.workflow_run_id is None:
|
||||
return
|
||||
@staticmethod
|
||||
def _load_execution(*, session: Session, scope: WorkflowAgentSessionScope) -> WorkflowNodeExecutionModel:
|
||||
return WorkflowAgentWorkspaceStore._load_execution_by_identity(
|
||||
session=session,
|
||||
tenant_id=scope.tenant_id,
|
||||
app_id=scope.app_id,
|
||||
workflow_id=scope.workflow_id,
|
||||
workflow_run_id=scope.workflow_run_id,
|
||||
node_id=scope.node_id,
|
||||
node_execution_id=scope.node_execution_id,
|
||||
)
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
row = session.scalar(
|
||||
select(WorkflowAgentRuntimeSession).where(
|
||||
WorkflowAgentRuntimeSession.tenant_id == scope.tenant_id,
|
||||
WorkflowAgentRuntimeSession.workflow_run_id == scope.workflow_run_id,
|
||||
WorkflowAgentRuntimeSession.node_id == scope.node_id,
|
||||
WorkflowAgentRuntimeSession.binding_id == scope.binding_id,
|
||||
WorkflowAgentRuntimeSession.agent_id == scope.agent_id,
|
||||
WorkflowAgentRuntimeSession.status == WorkflowAgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
if row is None:
|
||||
return
|
||||
if backend_run_id is not None:
|
||||
row.backend_run_id = backend_run_id
|
||||
row.status = WorkflowAgentRuntimeSessionStatus.CLEANED
|
||||
row.cleaned_at = naive_utc_now()
|
||||
session.commit()
|
||||
@staticmethod
|
||||
def _load_execution_by_identity(
|
||||
*,
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
app_id: str,
|
||||
workflow_id: str,
|
||||
workflow_run_id: str | None,
|
||||
node_id: str,
|
||||
node_execution_id: str,
|
||||
) -> WorkflowNodeExecutionModel:
|
||||
"""Wait briefly for the already-emitted node-start event to persist its caller row."""
|
||||
|
||||
stmt = select(WorkflowNodeExecutionModel).where(
|
||||
WorkflowNodeExecutionModel.id == node_execution_id,
|
||||
WorkflowNodeExecutionModel.tenant_id == tenant_id,
|
||||
WorkflowNodeExecutionModel.app_id == app_id,
|
||||
WorkflowNodeExecutionModel.workflow_id == workflow_id,
|
||||
WorkflowNodeExecutionModel.node_id == node_id,
|
||||
WorkflowNodeExecutionModel.workflow_run_id == workflow_run_id,
|
||||
)
|
||||
for attempt in range(_CALLER_VISIBILITY_ATTEMPTS):
|
||||
execution = session.scalar(stmt)
|
||||
if execution is not None:
|
||||
return execution
|
||||
if attempt < _CALLER_VISIBILITY_ATTEMPTS - 1:
|
||||
time.sleep(_CALLER_VISIBILITY_INTERVAL_SECONDS)
|
||||
|
||||
raise AgentWorkspaceNotFoundError("Workflow node execution caller is unavailable")
|
||||
|
||||
@staticmethod
|
||||
def _stored(scope: WorkflowAgentSessionScope, binding: AgentWorkspaceBinding) -> StoredWorkflowAgentSession:
|
||||
snapshot = (
|
||||
CompositorSessionSnapshot.model_validate_json(binding.session_snapshot)
|
||||
if binding.session_snapshot
|
||||
else None
|
||||
)
|
||||
return StoredWorkflowAgentSession(
|
||||
scope=scope,
|
||||
binding_id=binding.id,
|
||||
workspace_id=binding.workspace_id,
|
||||
backend_binding_ref=binding.backend_binding_ref,
|
||||
session_snapshot=snapshot,
|
||||
pending_form_id=binding.pending_form_id,
|
||||
pending_tool_call_id=binding.pending_tool_call_id,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"StoredWorkflowAgentSession",
|
||||
"WorkflowAgentRuntimeSessionStore",
|
||||
"WorkflowAgentSessionScope",
|
||||
]
|
||||
__all__ = ["StoredWorkflowAgentSession", "WorkflowAgentSessionScope", "WorkflowAgentWorkspaceStore"]
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Retire Workflow Agent Workspaces when the Workflow Run terminates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import override
|
||||
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext
|
||||
from core.workflow.nodes.agent_v2.session_store import WorkflowAgentWorkspaceStore
|
||||
from core.workflow.system_variables import SystemVariableKey, get_system_text
|
||||
from graphon.graph_engine.layers import GraphEngineLayer
|
||||
from graphon.graph_events import (
|
||||
GraphEngineEvent,
|
||||
GraphRunAbortedEvent,
|
||||
GraphRunFailedEvent,
|
||||
GraphRunPartialSucceededEvent,
|
||||
GraphRunSucceededEvent,
|
||||
)
|
||||
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkflowAgentWorkspaceRetirementLayer(GraphEngineLayer):
|
||||
"""Synchronously retire run Workspaces, then enqueue physical collection."""
|
||||
|
||||
_TERMINAL_EVENTS = (
|
||||
GraphRunSucceededEvent,
|
||||
GraphRunPartialSucceededEvent,
|
||||
GraphRunFailedEvent,
|
||||
GraphRunAbortedEvent,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
dify_run_context: DifyRunContext,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._dify_run_context = dify_run_context
|
||||
|
||||
@override
|
||||
def on_graph_start(self) -> None:
|
||||
return
|
||||
|
||||
@override
|
||||
def on_event(self, event: GraphEngineEvent) -> None:
|
||||
if not isinstance(event, self._TERMINAL_EVENTS):
|
||||
return
|
||||
workflow_run_id = get_system_text(
|
||||
self.graph_runtime_state.variable_pool,
|
||||
SystemVariableKey.WORKFLOW_EXECUTION_ID,
|
||||
)
|
||||
if not workflow_run_id:
|
||||
logger.warning("Skipping Workflow Agent Workspace retirement: workflow_run_id is missing")
|
||||
return
|
||||
try:
|
||||
workspace_ids = WorkflowAgentWorkspaceStore().retire_workflow_run(
|
||||
tenant_id=self._dify_run_context.tenant_id,
|
||||
app_id=self._dify_run_context.app_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to retire Workflow Agent Workspaces",
|
||||
extra={
|
||||
"tenant_id": self._dify_run_context.tenant_id,
|
||||
"app_id": self._dify_run_context.app_id,
|
||||
"workflow_run_id": workflow_run_id,
|
||||
},
|
||||
)
|
||||
return
|
||||
enqueue_agent_resource_collection(
|
||||
tenant_id=self._dify_run_context.tenant_id,
|
||||
workspace_ids=workspace_ids,
|
||||
)
|
||||
|
||||
@override
|
||||
def on_graph_end(self, error: Exception | None) -> None:
|
||||
return
|
||||
|
||||
|
||||
def build_workflow_agent_workspace_retirement_layer(
|
||||
*, dify_run_context: DifyRunContext
|
||||
) -> WorkflowAgentWorkspaceRetirementLayer:
|
||||
return WorkflowAgentWorkspaceRetirementLayer(dify_run_context=dify_run_context)
|
||||
|
||||
|
||||
__all__ = ["WorkflowAgentWorkspaceRetirementLayer", "build_workflow_agent_workspace_retirement_layer"]
|
||||
@@ -13,7 +13,6 @@ from typing import Annotated, Any, Literal, Self, assert_never, override
|
||||
|
||||
from pydantic import BaseModel, Field, NonNegativeInt, field_validator, model_validator
|
||||
|
||||
from core.workflow.human_input_adapter import DeliveryChannelConfig
|
||||
from graphon.entities.base_node_data import BaseNodeData
|
||||
from graphon.enums import BuiltinNodeTypes, NodeType
|
||||
from graphon.file.enums import FileTransferMethod, FileType
|
||||
@@ -373,10 +372,3 @@ def validate_human_input_submission(
|
||||
missing_list = ", ".join(missing_inputs)
|
||||
msg = f"Missing required inputs: {missing_list}"
|
||||
raise HumanInputSubmissionValidationError(msg)
|
||||
|
||||
|
||||
class HumanInputNodeDataFull(HumanInputNodeData):
|
||||
# This model is the full definition of HumanInputNodeData.
|
||||
#
|
||||
# The model above lacks some fields due to migration between Graphon and Dify. This model add them back.
|
||||
delivery_methods: list[DeliveryChannelConfig] = Field(default_factory=list[DeliveryChannelConfig])
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import enum
|
||||
from collections.abc import Sequence
|
||||
from typing import Annotated, Final, Literal
|
||||
|
||||
from pydantic import AfterValidator, BaseModel, ConfigDict, Discriminator, Field
|
||||
|
||||
from core.human_input_v2.entities import IMProvider
|
||||
from core.workflow.nodes.human_input.entities import FormInputConfig, TimeoutUnit, UserActionConfig
|
||||
from graphon.entities.base_node_data import BaseNodeData
|
||||
from graphon.enums import BuiltinNodeTypes, NodeType
|
||||
|
||||
|
||||
class RecipientType(enum.StrEnum):
|
||||
CONTACT = enum.auto()
|
||||
DYNAMIC_EMAIL = enum.auto()
|
||||
ONETIME_EMAIL = enum.auto()
|
||||
INITIATOR = enum.auto()
|
||||
|
||||
|
||||
class Contact(BaseModel):
|
||||
type: Literal[RecipientType.CONTACT] = RecipientType.CONTACT
|
||||
|
||||
contact_id: str
|
||||
|
||||
|
||||
class DynamicEmail(BaseModel):
|
||||
type: Literal[RecipientType.DYNAMIC_EMAIL] = RecipientType.DYNAMIC_EMAIL
|
||||
|
||||
selector: Sequence[str]
|
||||
|
||||
|
||||
class OnetimeEmail(BaseModel):
|
||||
type: Literal[RecipientType.ONETIME_EMAIL] = RecipientType.ONETIME_EMAIL
|
||||
|
||||
email: str
|
||||
|
||||
|
||||
class Initiator(BaseModel):
|
||||
type: Literal[RecipientType.INITIATOR] = RecipientType.INITIATOR
|
||||
|
||||
|
||||
RecipientConfig = Annotated[Contact | DynamicEmail | OnetimeEmail | Initiator, Discriminator("type")]
|
||||
|
||||
|
||||
class MessageTemplateConfig(BaseModel):
|
||||
subject: str
|
||||
body: str
|
||||
|
||||
|
||||
class Channel(enum.StrEnum):
|
||||
EMAIL = enum.auto()
|
||||
FEISHU = IMProvider.FEISHU.value
|
||||
SLACK = IMProvider.SLACK.value
|
||||
DING_TALK = IMProvider.DING_TALK.value
|
||||
MS_TEAMS = IMProvider.MS_TEAMS.value
|
||||
WE_COM = IMProvider.WE_COM.value
|
||||
LARK = IMProvider.LARK.value
|
||||
|
||||
|
||||
class DebugModeConfig(BaseModel):
|
||||
enabled: bool = False
|
||||
channels: Sequence[Channel]
|
||||
|
||||
|
||||
HUMAN_INPUT_V2_VERSION: Final = "2"
|
||||
|
||||
|
||||
def _version_validator(version: str) -> str:
|
||||
if version != HUMAN_INPUT_V2_VERSION:
|
||||
raise ValueError(f"Human Input v2 requires version='{HUMAN_INPUT_V2_VERSION}'")
|
||||
return version
|
||||
|
||||
|
||||
class HumanInputNodeData(BaseNodeData):
|
||||
"""Human Input node data."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True)
|
||||
|
||||
type: NodeType = BuiltinNodeTypes.HUMAN_INPUT
|
||||
version: Annotated[str, AfterValidator(_version_validator)] = HUMAN_INPUT_V2_VERSION
|
||||
|
||||
recipients_spec: list[RecipientConfig]
|
||||
|
||||
message_template: MessageTemplateConfig
|
||||
debug_mode: DebugModeConfig
|
||||
|
||||
form_content: str = ""
|
||||
inputs: list[FormInputConfig] = Field(default_factory=list[FormInputConfig])
|
||||
user_actions: list[UserActionConfig] = Field(default_factory=list[UserActionConfig])
|
||||
timeout: int = 36
|
||||
timeout_unit: TimeoutUnit = TimeoutUnit.HOUR
|
||||
@@ -166,6 +166,7 @@ def init_app(app: DifyApp) -> Celery:
|
||||
|
||||
imports = [
|
||||
"tasks.async_workflow_tasks", # trigger workers
|
||||
"tasks.collect_agent_resources_task", # retired Agent resource collection
|
||||
"tasks.trigger_processing_tasks", # async trigger processing
|
||||
"tasks.generate_summary_index_task", # summary index generation
|
||||
"tasks.regenerate_summary_index_task", # summary index regeneration
|
||||
|
||||
@@ -277,6 +277,12 @@ class LogstoreWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository):
|
||||
logger.exception("Failed to dual-write node execution to SQL database: id=%s", execution.id)
|
||||
# Don't raise - LogStore write succeeded, SQL is just a backup
|
||||
|
||||
@override
|
||||
def save_synchronously(self, execution: WorkflowNodeExecution) -> None:
|
||||
"""Create the SQL caller row required by Agent v2 participant ownership."""
|
||||
|
||||
self.sql_repository.save_synchronously(execution)
|
||||
|
||||
@override
|
||||
def save_execution_data(self, execution: WorkflowNodeExecution) -> None:
|
||||
"""
|
||||
|
||||
@@ -34,7 +34,7 @@ class _SessionResponseSource[SourceT]:
|
||||
self._session = session
|
||||
|
||||
def __getattr__(self, name: str) -> object:
|
||||
return getattr(self._source, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self._source, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
class _FeedbackResponseSource(_SessionResponseSource[MessageFeedback]):
|
||||
|
||||
@@ -227,7 +227,7 @@ class DatasetDetailResponseSource:
|
||||
return self.dataset.get_total_available_documents(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.dataset, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.dataset, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
def dataset_detail_response_source(dataset: Any, *, session: Session) -> DatasetDetailResponseSource:
|
||||
|
||||
@@ -90,7 +90,7 @@ class DocumentWithSession:
|
||||
return self.document.get_doc_metadata_details(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.document, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.document, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
def document_response(document: Document, *, session: Session) -> DocumentResponse:
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
from pydantic import Field, PositiveInt
|
||||
|
||||
|
||||
class PaginationParamsMixin:
|
||||
page: PositiveInt = Field(1, description="1-based page number.")
|
||||
limit: PositiveInt = Field(default=20, le=100, description="Maximum number of records returned per page.")
|
||||
|
||||
|
||||
class PaginationResultMixin:
|
||||
limit: int = Field(description="Page size used for the current query.")
|
||||
total: int = Field(description="Total number of candidates matching the current query.")
|
||||
page: int = Field(description="Current 1-based page number.")
|
||||
@@ -1,5 +0,0 @@
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
Timestamp = Annotated[int, Field(..., description="Unix timestamp in milliseconds")]
|
||||
+5
-1
@@ -289,7 +289,11 @@ UUIDStr = Annotated[str, AfterValidator(_strict_uuid)]
|
||||
|
||||
def alphanumeric(value: str):
|
||||
# check if the value is alphanumeric and underlined
|
||||
if re.match(r"^[a-zA-Z0-9_]+$", value):
|
||||
# Use re.fullmatch instead of re.match to reject trailing newlines.
|
||||
# In Python, '$' matches at end-of-string OR just before a trailing newline,
|
||||
# so re.match accepts "tool_name\n". re.fullmatch requires the entire
|
||||
# string to match. Regression for #39666 (sibling of #39234 / #39548).
|
||||
if re.fullmatch(r"^[a-zA-Z0-9_]+$", value):
|
||||
return value
|
||||
|
||||
raise ValueError(f"{value} is not a valid alphanumeric value")
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
"""add agent home snapshot ledger
|
||||
|
||||
Revision ID: 2f39536b3feb
|
||||
Revises: 6f5a9c2d8e1b
|
||||
Create Date: 2026-07-21 22:51:07.268658
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import models as models
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2f39536b3feb'
|
||||
down_revision = '6f5a9c2d8e1b'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('agent_home_snapshots',
|
||||
sa.Column('id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('tenant_id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('agent_id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('snapshot_ref', sa.String(length=255), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name='agent_home_snapshot_pkey')
|
||||
)
|
||||
with op.batch_alter_table('agent_home_snapshots', schema=None) as batch_op:
|
||||
batch_op.create_index('agent_home_snapshot_tenant_agent_idx', ['tenant_id', 'agent_id'], unique=False)
|
||||
|
||||
with op.batch_alter_table('agent_config_drafts', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('home_snapshot_id', models.types.StringUUID(), nullable=False))
|
||||
|
||||
with op.batch_alter_table('agent_config_snapshots', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('home_snapshot_id', models.types.StringUUID(), nullable=False))
|
||||
|
||||
with op.batch_alter_table('agent_runtime_sessions', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('home_snapshot_id', models.types.StringUUID(), nullable=False))
|
||||
batch_op.drop_index(batch_op.f('agent_runtime_session_conversation_scope_unique'), postgresql_where='(conversation_id IS NOT NULL)')
|
||||
batch_op.create_index('agent_runtime_session_conversation_scope_unique', ['tenant_id', 'conversation_id', 'agent_id', 'agent_config_snapshot_id', 'home_snapshot_id'], unique=True, postgresql_where=sa.text('conversation_id IS NOT NULL'))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('agent_runtime_sessions', schema=None) as batch_op:
|
||||
batch_op.drop_index('agent_runtime_session_conversation_scope_unique', postgresql_where=sa.text('conversation_id IS NOT NULL'))
|
||||
batch_op.create_index(batch_op.f('agent_runtime_session_conversation_scope_unique'), ['tenant_id', 'conversation_id', 'agent_id', 'agent_config_snapshot_id'], unique=True, postgresql_where='(conversation_id IS NOT NULL)')
|
||||
batch_op.drop_column('home_snapshot_id')
|
||||
|
||||
with op.batch_alter_table('agent_config_snapshots', schema=None) as batch_op:
|
||||
batch_op.drop_column('home_snapshot_id')
|
||||
|
||||
with op.batch_alter_table('agent_config_drafts', schema=None) as batch_op:
|
||||
batch_op.drop_column('home_snapshot_id')
|
||||
|
||||
with op.batch_alter_table('agent_home_snapshots', schema=None) as batch_op:
|
||||
batch_op.drop_index('agent_home_snapshot_tenant_agent_idx')
|
||||
|
||||
op.drop_table('agent_home_snapshots')
|
||||
# ### end Alembic commands ###
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
"""replace agent runtime sessions with workspaces and bindings
|
||||
|
||||
Revision ID: f6e4c5686857
|
||||
Revises: 2f39536b3feb
|
||||
Create Date: 2026-07-23 02:03:05.641638
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import models as models
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f6e4c5686857'
|
||||
down_revision = '2f39536b3feb'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('agent_workspace_bindings',
|
||||
sa.Column('tenant_id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('app_id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('workspace_id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('agent_id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('base_home_snapshot_id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('agent_config_version_id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('agent_config_version_kind', sa.String(length=32), nullable=False),
|
||||
sa.Column('backend_binding_ref', sa.String(length=255), nullable=False),
|
||||
sa.Column('session_snapshot', models.types.LongText(), nullable=True),
|
||||
sa.Column('status', sa.String(length=32), server_default='active', nullable=False),
|
||||
sa.Column('retired_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('pending_form_id', models.types.StringUUID(), nullable=True),
|
||||
sa.Column('pending_tool_call_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name='agent_workspace_binding_pkey')
|
||||
)
|
||||
with op.batch_alter_table('agent_workspace_bindings', schema=None) as batch_op:
|
||||
batch_op.create_index('agent_workspace_binding_agent_status_idx', ['tenant_id', 'agent_id', 'status'], unique=False)
|
||||
batch_op.create_index('agent_workspace_binding_status_retired_idx', ['status', 'retired_at'], unique=False)
|
||||
batch_op.create_index('agent_workspace_binding_workspace_status_idx', ['tenant_id', 'workspace_id', 'status'], unique=False)
|
||||
|
||||
op.create_table('agent_workspaces',
|
||||
sa.Column('tenant_id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('app_id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('owner_type', sa.String(length=32), nullable=False),
|
||||
sa.Column('owner_id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('owner_scope_key', sa.String(length=255), nullable=False),
|
||||
sa.Column('backend_workspace_ref', sa.String(length=255), nullable=False),
|
||||
sa.Column('status', sa.String(length=32), server_default='active', nullable=False),
|
||||
sa.Column('active_guard', sa.SmallInteger(), server_default='1', nullable=True),
|
||||
sa.Column('retired_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name='agent_workspace_pkey')
|
||||
)
|
||||
with op.batch_alter_table('agent_workspaces', schema=None) as batch_op:
|
||||
batch_op.create_index('agent_workspace_owner_active_unique', ['tenant_id', 'owner_type', 'owner_id', 'owner_scope_key', 'active_guard'], unique=True)
|
||||
batch_op.create_index('agent_workspace_status_retired_idx', ['status', 'retired_at'], unique=False)
|
||||
batch_op.create_index('agent_workspace_tenant_app_status_idx', ['tenant_id', 'app_id', 'status'], unique=False)
|
||||
batch_op.create_index('agent_workspace_tenant_status_idx', ['tenant_id', 'status'], unique=False)
|
||||
|
||||
with op.batch_alter_table('agent_runtime_sessions', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('agent_runtime_session_backend_run_idx'))
|
||||
batch_op.drop_index(batch_op.f('agent_runtime_session_conversation_lookup_idx'))
|
||||
batch_op.drop_index(batch_op.f('agent_runtime_session_conversation_scope_unique'), postgresql_where='(conversation_id IS NOT NULL)')
|
||||
batch_op.drop_index(batch_op.f('agent_runtime_session_workflow_lookup_idx'))
|
||||
batch_op.drop_index(batch_op.f('agent_runtime_session_workflow_scope_unique'), postgresql_where='(workflow_run_id IS NOT NULL)')
|
||||
|
||||
op.drop_table('agent_runtime_sessions')
|
||||
with op.batch_alter_table('agent_home_snapshots', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('status', sa.String(length=32), server_default='active', nullable=False))
|
||||
batch_op.add_column(sa.Column('retired_at', sa.DateTime(), nullable=True))
|
||||
batch_op.create_index('agent_home_snapshot_status_retired_idx', ['status', 'retired_at'], unique=False)
|
||||
|
||||
with op.batch_alter_table('conversations', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('agent_workspace_binding_id', models.types.StringUUID(), nullable=True))
|
||||
|
||||
with op.batch_alter_table('agent_config_drafts', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('agent_workspace_binding_id', models.types.StringUUID(), nullable=True))
|
||||
|
||||
with op.batch_alter_table('workflow_node_executions', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('agent_workspace_binding_id', models.types.StringUUID(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('workflow_node_executions', schema=None) as batch_op:
|
||||
batch_op.drop_column('agent_workspace_binding_id')
|
||||
|
||||
with op.batch_alter_table('agent_config_drafts', schema=None) as batch_op:
|
||||
batch_op.drop_column('agent_workspace_binding_id')
|
||||
|
||||
with op.batch_alter_table('conversations', schema=None) as batch_op:
|
||||
batch_op.drop_column('agent_workspace_binding_id')
|
||||
|
||||
with op.batch_alter_table('agent_home_snapshots', schema=None) as batch_op:
|
||||
batch_op.drop_index('agent_home_snapshot_status_retired_idx')
|
||||
batch_op.drop_column('retired_at')
|
||||
batch_op.drop_column('status')
|
||||
|
||||
op.create_table('agent_runtime_sessions',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('uuidv7()'), autoincrement=False, nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||
sa.Column('app_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||
sa.Column('owner_type', sa.VARCHAR(length=32), autoincrement=False, nullable=False),
|
||||
sa.Column('agent_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||
sa.Column('backend_run_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
|
||||
sa.Column('session_snapshot', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('workflow_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||
sa.Column('workflow_run_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||
sa.Column('node_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
|
||||
sa.Column('node_execution_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
|
||||
sa.Column('binding_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||
sa.Column('agent_config_snapshot_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||
sa.Column('composition_layer_specs', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('conversation_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||
sa.Column('status', sa.VARCHAR(length=32), server_default=sa.text("'active'::character varying"), autoincrement=False, nullable=False),
|
||||
sa.Column('cleaned_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=False),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=False),
|
||||
sa.Column('pending_form_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||
sa.Column('pending_tool_call_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
|
||||
sa.Column('home_snapshot_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('agent_runtime_session_pkey'))
|
||||
)
|
||||
with op.batch_alter_table('agent_runtime_sessions', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('agent_runtime_session_workflow_scope_unique'), ['tenant_id', 'workflow_run_id', 'node_id', 'binding_id', 'agent_id'], unique=True, postgresql_where='(workflow_run_id IS NOT NULL)')
|
||||
batch_op.create_index(batch_op.f('agent_runtime_session_workflow_lookup_idx'), ['tenant_id', 'workflow_run_id', 'node_id', 'status'], unique=False)
|
||||
batch_op.create_index(batch_op.f('agent_runtime_session_conversation_scope_unique'), ['tenant_id', 'conversation_id', 'agent_id', 'agent_config_snapshot_id', 'home_snapshot_id'], unique=True, postgresql_where='(conversation_id IS NOT NULL)')
|
||||
batch_op.create_index(batch_op.f('agent_runtime_session_conversation_lookup_idx'), ['tenant_id', 'conversation_id', 'status'], unique=False)
|
||||
batch_op.create_index(batch_op.f('agent_runtime_session_backend_run_idx'), ['backend_run_id'], unique=False)
|
||||
|
||||
with op.batch_alter_table('agent_workspaces', schema=None) as batch_op:
|
||||
batch_op.drop_index('agent_workspace_tenant_status_idx')
|
||||
batch_op.drop_index('agent_workspace_tenant_app_status_idx')
|
||||
batch_op.drop_index('agent_workspace_status_retired_idx')
|
||||
batch_op.drop_index('agent_workspace_owner_active_unique')
|
||||
|
||||
op.drop_table('agent_workspaces')
|
||||
with op.batch_alter_table('agent_workspace_bindings', schema=None) as batch_op:
|
||||
batch_op.drop_index('agent_workspace_binding_workspace_status_idx')
|
||||
batch_op.drop_index('agent_workspace_binding_status_retired_idx')
|
||||
batch_op.drop_index('agent_workspace_binding_agent_status_idx')
|
||||
|
||||
op.drop_table('agent_workspace_bindings')
|
||||
# ### end Alembic commands ###
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
"""add human input v2 contact directory
|
||||
|
||||
Revision ID: 5c8f1a2b3d4e
|
||||
Revises: d2825e7b9c10
|
||||
Create Date: 2026-07-25 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "5c8f1a2b3d4e"
|
||||
down_revision = "d2825e7b9c10"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"human_input_contacts",
|
||||
sa.Column("name", sa.String(length=255), nullable=False, comment="Display name shown in contact surfaces."),
|
||||
sa.Column(
|
||||
"normalized_name",
|
||||
sa.String(length=255),
|
||||
nullable=False,
|
||||
comment="Lower-cased search value maintained by the application.",
|
||||
),
|
||||
sa.Column(
|
||||
"identity_source",
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
comment="Immutable identity source that determines the Contact lifecycle owner.",
|
||||
),
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment=(
|
||||
"Ownership boundary: null only for EE Organization contacts; otherwise the owning tenants.id for "
|
||||
"workspace-owned contacts. CE and SaaS must never persist a null value."
|
||||
),
|
||||
),
|
||||
sa.Column(
|
||||
"account_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical foreign key to accounts.id for an account-backed contact.",
|
||||
),
|
||||
sa.Column(
|
||||
"email",
|
||||
sa.String(length=320),
|
||||
nullable=True,
|
||||
comment="Current deliverable email address, when available.",
|
||||
),
|
||||
sa.Column(
|
||||
"normalized_email",
|
||||
sa.String(length=320),
|
||||
nullable=True,
|
||||
comment="Full lower-cased email used for equality matching.",
|
||||
),
|
||||
sa.Column(
|
||||
"avatar_file_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical foreign key to upload_files.id for an external contact avatar.",
|
||||
),
|
||||
sa.Column("id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name="human_input_contacts_pkey"),
|
||||
sa.UniqueConstraint("tenant_id", "account_id", name="human_input_contacts_tenant_account_uq"),
|
||||
sa.UniqueConstraint("tenant_id", "normalized_email", name="human_input_contacts_tenant_email_uq"),
|
||||
sa.CheckConstraint(
|
||||
"(identity_source = 'organization_account' AND tenant_id IS NULL AND account_id IS NOT NULL) OR "
|
||||
"(identity_source = 'workspace_member' AND tenant_id IS NOT NULL AND account_id IS NOT NULL) OR "
|
||||
"(identity_source = 'external' AND tenant_id IS NOT NULL AND account_id IS NULL)",
|
||||
name="identity_owner",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"identity_source <> 'external' OR (email IS NOT NULL AND normalized_email IS NOT NULL)",
|
||||
name="external_email",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(email IS NULL AND normalized_email IS NULL) OR (email IS NOT NULL AND normalized_email IS NOT NULL)",
|
||||
name="email_normalization_pair",
|
||||
),
|
||||
comment=(
|
||||
"Canonical Human Input contact identities. EE Organization Account contacts have tenant_id IS NULL; "
|
||||
"workspace-owned contacts have tenant_id = tenants.id; CE and SaaS must not create contacts with "
|
||||
"tenant_id IS NULL."
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"human_input_contacts_tenant_normalized_name_idx",
|
||||
"human_input_contacts",
|
||||
["tenant_id", "normalized_name"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_platform_contact_workspace_entries",
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column(
|
||||
"contact_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical foreign key to human_input_contacts.id.",
|
||||
),
|
||||
sa.Column(
|
||||
"added_by_account_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical foreign key to accounts.id for the administrator who added this directory entry.",
|
||||
),
|
||||
sa.Column("id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name="human_input_platform_contact_workspace_entries_pkey"),
|
||||
sa.UniqueConstraint("tenant_id", "contact_id", name="hipcwe_tenant_contact_uq"),
|
||||
comment=(
|
||||
"EE-only workspace allow-list for Organization Account contacts. Workspace membership and External "
|
||||
"contact ownership must not create rows in this table."
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"hipcwe_tenant_created_at_id_idx",
|
||||
"human_input_platform_contact_workspace_entries",
|
||||
["tenant_id", "created_at", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"hipcwe_contact_id_idx",
|
||||
"human_input_platform_contact_workspace_entries",
|
||||
["contact_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("human_input_platform_contact_workspace_entries")
|
||||
op.drop_table("human_input_contacts")
|
||||
-320
@@ -1,320 +0,0 @@
|
||||
"""add human input v2 im control plane
|
||||
|
||||
Revision ID: 6d9f2b4c5e7a
|
||||
Revises: 5c8f1a2b3d4e
|
||||
Create Date: 2026-07-25 11:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "6d9f2b4c5e7a"
|
||||
down_revision = "5c8f1a2b3d4e"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _default_fields(table_name: str) -> tuple[sa.Column, sa.Column, sa.Column, sa.PrimaryKeyConstraint]:
|
||||
return (
|
||||
sa.Column("id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=f"{table_name}_pkey"),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"human_input_im_integrations",
|
||||
sa.Column("provider", sa.String(length=20), nullable=False, comment="Configured IM provider discriminator."),
|
||||
sa.Column(
|
||||
"encrypted_credentials",
|
||||
models.types.LongText(),
|
||||
nullable=False,
|
||||
comment="Provider-specific encrypted credential model serialized as JSON text.",
|
||||
),
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical tenants.id owner in CE/SaaS; null for an EE deployment-wide integration.",
|
||||
),
|
||||
sa.Column(
|
||||
"provider_tenant_id",
|
||||
sa.String(length=255),
|
||||
nullable=False,
|
||||
comment="Confirmed provider-side organization or workspace identity.",
|
||||
),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, comment="Last connectivity diagnostic status."),
|
||||
sa.Column(
|
||||
"config_version",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
comment="Monotonic configuration revision used with the integration ID for CAS.",
|
||||
),
|
||||
sa.Column(
|
||||
"configured_by_account_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical accounts.id for the latest configuration writer.",
|
||||
),
|
||||
sa.Column("callback_url", sa.String(length=1024), nullable=True, comment="Configured callback URL."),
|
||||
sa.Column(
|
||||
"safe_status_reason",
|
||||
models.types.LongText(),
|
||||
nullable=True,
|
||||
comment="Operator-safe connection diagnostic.",
|
||||
),
|
||||
sa.Column("last_checked_at", sa.DateTime(), nullable=True, comment="Latest connectivity check timestamp."),
|
||||
*_default_fields("human_input_im_integrations"),
|
||||
sa.UniqueConstraint("tenant_id", name="human_input_im_integrations_tenant_uq"),
|
||||
sa.CheckConstraint("config_version > 0", name="config_version_positive"),
|
||||
comment="Organization-level Human Input IM integration configuration.",
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_im_identities",
|
||||
sa.Column(
|
||||
"integration_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_im_integrations.id owner.",
|
||||
),
|
||||
sa.Column("provider", sa.String(length=20), nullable=False, comment="Provider identity discriminator."),
|
||||
sa.Column(
|
||||
"provider_user_id", sa.String(length=255), nullable=False, comment="Provider user matching identity."
|
||||
),
|
||||
sa.Column("display_name", sa.String(length=255), nullable=True, comment="Latest provider display name."),
|
||||
sa.Column("normalized_name", sa.String(length=255), nullable=True, comment="Case-folded provider name."),
|
||||
sa.Column("email", sa.String(length=320), nullable=True, comment="Latest provider email."),
|
||||
sa.Column(
|
||||
"normalized_email", sa.String(length=320), nullable=True, comment="Case-folded fallback matching email."
|
||||
),
|
||||
sa.Column(
|
||||
"raw_payload",
|
||||
models.types.LongText(),
|
||||
nullable=False,
|
||||
comment="Opaque provider payload serialized as JSON text for diagnostics.",
|
||||
),
|
||||
sa.Column(
|
||||
"last_seen_sync_run_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical human_input_im_sync_runs.id that last observed this identity.",
|
||||
),
|
||||
sa.Column("last_seen_at", sa.DateTime(), nullable=True, comment="Timestamp last observed."),
|
||||
*_default_fields("human_input_im_identities"),
|
||||
sa.UniqueConstraint(
|
||||
"integration_id",
|
||||
"provider",
|
||||
"provider_user_id",
|
||||
name="human_input_im_identities_integration_provider_user_uq",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(email IS NULL AND normalized_email IS NULL) OR (email IS NOT NULL AND normalized_email IS NOT NULL)",
|
||||
name="email_normalization_pair",
|
||||
),
|
||||
comment="Current synchronized IM directory identities.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiimi_integration_provider_email_idx",
|
||||
"human_input_im_identities",
|
||||
["integration_id", "provider", "normalized_email"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiimi_integration_provider_name_idx",
|
||||
"human_input_im_identities",
|
||||
["integration_id", "provider", "normalized_name"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiimi_integration_last_seen_run_idx",
|
||||
"human_input_im_identities",
|
||||
["integration_id", "last_seen_sync_run_id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_im_bindings",
|
||||
sa.Column(
|
||||
"integration_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_im_integrations.id owner.",
|
||||
),
|
||||
sa.Column("scope", sa.String(length=20), nullable=False, comment="Organization or workspace scope."),
|
||||
sa.Column("scope_id", models.types.StringUUID(), nullable=False, comment="Scope owner identity."),
|
||||
sa.Column("contact_id", models.types.StringUUID(), nullable=False, comment="Logical human_input_contacts.id."),
|
||||
sa.Column(
|
||||
"im_identity_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_im_identities.id.",
|
||||
),
|
||||
sa.Column("provider", sa.String(length=20), nullable=False, comment="Denormalized provider discriminator."),
|
||||
sa.Column(
|
||||
"bound_by_account_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical accounts.id for an administrative override.",
|
||||
),
|
||||
*_default_fields("human_input_im_bindings"),
|
||||
sa.UniqueConstraint(
|
||||
"scope",
|
||||
"scope_id",
|
||||
"contact_id",
|
||||
"provider",
|
||||
name="human_input_im_bindings_scope_contact_provider_uq",
|
||||
),
|
||||
sa.UniqueConstraint("scope", "scope_id", "im_identity_id", name="human_input_im_bindings_scope_identity_uq"),
|
||||
sa.CheckConstraint(
|
||||
"scope <> 'organization' OR scope_id = integration_id",
|
||||
name="organization_scope_owner",
|
||||
),
|
||||
comment="Current organization binding or workspace override.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiimb_integration_contact_provider_scope_idx",
|
||||
"human_input_im_bindings",
|
||||
["integration_id", "contact_id", "provider", "scope", "scope_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiimb_identity_scope_idx",
|
||||
"human_input_im_bindings",
|
||||
["im_identity_id", "scope", "scope_id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_im_sync_runs",
|
||||
sa.Column(
|
||||
"integration_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_im_integrations.id owner.",
|
||||
),
|
||||
sa.Column(
|
||||
"integration_config_version",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
comment="Captured integration configuration revision.",
|
||||
),
|
||||
sa.Column("provider", sa.String(length=20), nullable=False, comment="Captured provider discriminator."),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, comment="Synchronization lifecycle state."),
|
||||
sa.Column("added_count", sa.Integer(), nullable=False, comment="Newly matched and bound entries."),
|
||||
sa.Column("not_matched_count", sa.Integer(), nullable=False, comment="Unmatched entries."),
|
||||
sa.Column("failed_count", sa.Integer(), nullable=False, comment="Failed entries."),
|
||||
sa.Column(
|
||||
"removed_count",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
comment="Removed binding facts, including one unbound-identity fact when applicable.",
|
||||
),
|
||||
sa.Column("skipped_count", sa.Integer(), nullable=False, comment="Intentionally skipped entries."),
|
||||
sa.Column(
|
||||
"started_by_account_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical accounts.id for the trigger actor.",
|
||||
),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=True, comment="Worker start timestamp."),
|
||||
sa.Column("finished_at", sa.DateTime(), nullable=True, comment="Terminal timestamp."),
|
||||
sa.Column("error_code", sa.String(length=100), nullable=True, comment="Machine-readable terminal error."),
|
||||
sa.Column("error_message", models.types.LongText(), nullable=True, comment="Operator-safe terminal error."),
|
||||
*_default_fields("human_input_im_sync_runs"),
|
||||
sa.CheckConstraint("integration_config_version > 0", name="captured_version_positive"),
|
||||
sa.CheckConstraint(
|
||||
"added_count >= 0 AND not_matched_count >= 0 AND failed_count >= 0 AND removed_count >= 0 "
|
||||
"AND skipped_count >= 0",
|
||||
name="result_counts_nonnegative",
|
||||
),
|
||||
comment="Manual IM directory synchronization lifecycle and counts.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiimsr_integration_created_idx",
|
||||
"human_input_im_sync_runs",
|
||||
["integration_id", "created_at", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiimsr_integration_status_created_idx",
|
||||
"human_input_im_sync_runs",
|
||||
["integration_id", "status", "created_at"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_im_sync_results",
|
||||
sa.Column(
|
||||
"integration_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Denormalized logical human_input_im_integrations.id.",
|
||||
),
|
||||
sa.Column(
|
||||
"sync_run_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_im_sync_runs.id.",
|
||||
),
|
||||
sa.Column("result_type", sa.String(length=20), nullable=False, comment="Stable result bucket."),
|
||||
sa.Column("provider_user_id", sa.String(length=255), nullable=True, comment="Observed provider user ID."),
|
||||
sa.Column("display_name", sa.String(length=255), nullable=True, comment="Observed provider display name."),
|
||||
sa.Column("email", sa.String(length=320), nullable=True, comment="Observed provider email."),
|
||||
sa.Column("normalized_email", sa.String(length=320), nullable=True, comment="Normalized matching email."),
|
||||
sa.Column(
|
||||
"contact_id", models.types.StringUUID(), nullable=True, comment="Historical logical Contact identity."
|
||||
),
|
||||
sa.Column(
|
||||
"im_identity_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Historical logical IM identity.",
|
||||
),
|
||||
sa.Column("im_binding_id", models.types.StringUUID(), nullable=True, comment="Historical logical IM binding."),
|
||||
sa.Column("removal_reason", sa.String(length=32), nullable=True, comment="Stable removal reason."),
|
||||
sa.Column("reason_code", sa.String(length=100), nullable=True, comment="Machine-readable diagnostic reason."),
|
||||
sa.Column("reason_message", models.types.LongText(), nullable=True, comment="Operator-safe diagnostic."),
|
||||
sa.Column(
|
||||
"directory_entry_payload",
|
||||
models.types.LongText(),
|
||||
nullable=True,
|
||||
comment="Immutable provider entry JSON observed by this run.",
|
||||
),
|
||||
sa.Column(
|
||||
"contact_snapshot",
|
||||
models.types.LongText(),
|
||||
nullable=True,
|
||||
comment="Immutable Contact display snapshot JSON.",
|
||||
),
|
||||
sa.Column(
|
||||
"identity_snapshot",
|
||||
models.types.LongText(),
|
||||
nullable=True,
|
||||
comment="Immutable removed identity snapshot JSON.",
|
||||
),
|
||||
*_default_fields("human_input_im_sync_results"),
|
||||
comment="Append-only per-entry, removed-binding, and diagnostic IM synchronization outcomes.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiimsres_run_type_created_idx",
|
||||
"human_input_im_sync_results",
|
||||
["sync_run_id", "result_type", "created_at", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiimsres_integration_contact_created_idx",
|
||||
"human_input_im_sync_results",
|
||||
["integration_id", "contact_id", "created_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiimsres_integration_identity_created_idx",
|
||||
"human_input_im_sync_results",
|
||||
["integration_id", "im_identity_id", "created_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("human_input_im_sync_results")
|
||||
op.drop_table("human_input_im_sync_runs")
|
||||
op.drop_table("human_input_im_bindings")
|
||||
op.drop_table("human_input_im_identities")
|
||||
op.drop_table("human_input_im_integrations")
|
||||
@@ -1,290 +0,0 @@
|
||||
"""add human input v2 form core
|
||||
|
||||
Revision ID: 8a1c4e7f9b2d
|
||||
Revises: 6d9f2b4c5e7a
|
||||
Create Date: 2026-07-25 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "8a1c4e7f9b2d"
|
||||
down_revision = "6d9f2b4c5e7a"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _default_fields(table_name: str) -> tuple[sa.Column, sa.Column, sa.Column, sa.PrimaryKeyConstraint]:
|
||||
return (
|
||||
sa.Column("id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=f"{table_name}_pkey"),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"human_input_email_providers",
|
||||
sa.Column("provider", sa.String(length=20), nullable=False, comment="Configured email provider discriminator."),
|
||||
sa.Column("sender_email", sa.String(length=320), nullable=False, comment="Configured sender email address."),
|
||||
sa.Column(
|
||||
"encrypted_credentials",
|
||||
models.types.LongText(),
|
||||
nullable=False,
|
||||
comment="Encrypted Resend credential Pydantic model serialized as JSON text.",
|
||||
),
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column("sender_name", sa.String(length=255), nullable=False, comment="Optional sender display name."),
|
||||
sa.Column(
|
||||
"configured_by_account_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical foreign key to accounts.id for the latest configuration write.",
|
||||
),
|
||||
*_default_fields("human_input_email_providers"),
|
||||
sa.UniqueConstraint("tenant_id", name="human_input_email_providers_tenant_uq"),
|
||||
comment="Workspace-level Human Input email delivery configuration.",
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_v2_forms",
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column("app_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to apps.id."),
|
||||
sa.Column(
|
||||
"form_definition",
|
||||
models.types.LongText(),
|
||||
nullable=False,
|
||||
comment="Frozen Human Input v2 form definition serialized as JSON text.",
|
||||
),
|
||||
sa.Column("rendered_content", models.types.LongText(), nullable=False, comment="Frozen rendered content."),
|
||||
sa.Column("node_timeout_at", sa.DateTime(), nullable=False, comment="Frozen node-level timeout timestamp."),
|
||||
sa.Column("global_expires_at", sa.DateTime(), nullable=False, comment="Frozen global expiration timestamp."),
|
||||
sa.Column("form_kind", sa.String(length=20), nullable=False, comment="Human Input v2 form ownership kind."),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, comment="Current form lifecycle state."),
|
||||
sa.Column(
|
||||
"workflow_pause_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical foreign key to workflow_pauses.id.",
|
||||
),
|
||||
sa.Column(
|
||||
"node_execution_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical foreign key to workflow_node_executions.id.",
|
||||
),
|
||||
*_default_fields("human_input_v2_forms"),
|
||||
sa.UniqueConstraint("workflow_pause_id", name="hiv2_forms_workflow_pause_uq"),
|
||||
sa.UniqueConstraint("node_execution_id", name="hiv2_forms_node_execution_uq"),
|
||||
sa.CheckConstraint(
|
||||
"form_kind <> 'runtime' OR (workflow_pause_id IS NOT NULL AND node_execution_id IS NOT NULL)",
|
||||
name="runtime_owner",
|
||||
),
|
||||
comment="Independent Human Input v2 form roots bound only to shared workflow pause infrastructure.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_forms_tenant_status_node_timeout_idx",
|
||||
"human_input_v2_forms",
|
||||
["tenant_id", "status", "node_timeout_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_forms_tenant_status_global_expiry_idx",
|
||||
"human_input_v2_forms",
|
||||
["tenant_id", "status", "global_expires_at"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_v2_form_approver_grants",
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column(
|
||||
"form_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical foreign key to human_input_v2_forms.id.",
|
||||
),
|
||||
sa.Column("subject_type", sa.String(length=20), nullable=False, comment="Approval subject discriminator."),
|
||||
sa.Column("subject_key", sa.String(length=255), nullable=False, comment="Portable subject deduplication key."),
|
||||
sa.Column(
|
||||
"matched_sources",
|
||||
models.types.LongText(),
|
||||
nullable=False,
|
||||
comment="Immutable ordered recipient source snapshots serialized as JSON text.",
|
||||
),
|
||||
sa.Column(
|
||||
"subject_snapshot",
|
||||
models.types.LongText(),
|
||||
nullable=False,
|
||||
comment="Immutable display-only subject snapshot serialized as JSON text.",
|
||||
),
|
||||
sa.Column("contact_id", models.types.StringUUID(), nullable=True, comment="Logical human_input_contacts.id."),
|
||||
sa.Column("end_user_id", models.types.StringUUID(), nullable=True, comment="Logical end_users.id."),
|
||||
sa.Column("normalized_email", sa.String(length=320), nullable=True, comment="Normalized Email subject."),
|
||||
*_default_fields("human_input_v2_form_approver_grants"),
|
||||
sa.UniqueConstraint("form_id", "subject_key", name="hiv2_form_grants_form_subject_uq"),
|
||||
sa.CheckConstraint(
|
||||
"(subject_type = 'contact' AND contact_id IS NOT NULL AND end_user_id IS NULL "
|
||||
"AND normalized_email IS NULL) OR "
|
||||
"(subject_type = 'end_user' AND contact_id IS NULL AND end_user_id IS NOT NULL "
|
||||
"AND normalized_email IS NULL) OR "
|
||||
"(subject_type = 'email_address' AND contact_id IS NULL AND end_user_id IS NULL "
|
||||
"AND normalized_email IS NOT NULL)",
|
||||
name="subject_identity",
|
||||
),
|
||||
comment="Frozen Human Input v2 form approval grants resolved from runtime recipients.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_grants_form_contact_idx",
|
||||
"human_input_v2_form_approver_grants",
|
||||
["form_id", "contact_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_grants_form_end_user_idx",
|
||||
"human_input_v2_form_approver_grants",
|
||||
["form_id", "end_user_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_grants_form_email_idx",
|
||||
"human_input_v2_form_approver_grants",
|
||||
["form_id", "normalized_email"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_v2_form_delivery_endpoints",
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column("form_id", models.types.StringUUID(), nullable=False, comment="Logical human_input_v2_forms.id."),
|
||||
sa.Column(
|
||||
"approver_grant_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_v2_form_approver_grants.id.",
|
||||
),
|
||||
sa.Column("channel", sa.String(length=20), nullable=False, comment="Delivery or interaction channel."),
|
||||
sa.Column("address_hash", sa.String(length=64), nullable=False, comment="Canonical endpoint SHA-256."),
|
||||
sa.Column("email_address", sa.String(length=320), nullable=True, comment="Frozen Email endpoint address."),
|
||||
sa.Column("integration_id", models.types.StringUUID(), nullable=True, comment="Logical IM integration id."),
|
||||
sa.Column("provider", sa.String(length=20), nullable=True, comment="Frozen IM provider."),
|
||||
sa.Column("provider_user_id", sa.String(length=255), nullable=True, comment="Frozen provider user id."),
|
||||
sa.Column("provider_tenant_id", sa.String(length=255), nullable=True, comment="Frozen provider tenant id."),
|
||||
sa.Column("im_identity_id", models.types.StringUUID(), nullable=True, comment="Historical IM identity id."),
|
||||
sa.Column("im_binding_id", models.types.StringUUID(), nullable=True, comment="Historical IM binding id."),
|
||||
sa.Column("access_token_hash", sa.String(length=64), nullable=True, comment="Hashed endpoint capability."),
|
||||
*_default_fields("human_input_v2_form_delivery_endpoints"),
|
||||
sa.UniqueConstraint(
|
||||
"form_id",
|
||||
"approver_grant_id",
|
||||
"channel",
|
||||
"address_hash",
|
||||
name="hiv2_form_endpoints_grant_channel_address_uq",
|
||||
),
|
||||
sa.UniqueConstraint("access_token_hash", name="hiv2_form_endpoints_token_uq"),
|
||||
comment="Immutable notification and interaction endpoints for Human Input v2 approver grants.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_endpoints_identity_form_idx",
|
||||
"human_input_v2_form_delivery_endpoints",
|
||||
["im_identity_id", "form_id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_v2_form_delivery_attempts",
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column("form_id", models.types.StringUUID(), nullable=False, comment="Logical human_input_v2_forms.id."),
|
||||
sa.Column(
|
||||
"endpoint_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_v2_form_delivery_endpoints.id.",
|
||||
),
|
||||
sa.Column("attempt_number", sa.Integer(), nullable=False, comment="One-based endpoint retry sequence."),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, comment="Delivery attempt lifecycle."),
|
||||
sa.Column("scheduled_at", sa.DateTime(), nullable=False, comment="Eligibility timestamp."),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=True, comment="Provider delivery start timestamp."),
|
||||
sa.Column("finished_at", sa.DateTime(), nullable=True, comment="Terminal timestamp."),
|
||||
sa.Column("provider_message_id", sa.String(length=255), nullable=True, comment="Provider message id."),
|
||||
sa.Column("failure_code", sa.String(length=100), nullable=True, comment="Failure code."),
|
||||
sa.Column("failure_reason", models.types.LongText(), nullable=True, comment="Failure diagnostic."),
|
||||
sa.Column("provider_response", models.types.LongText(), nullable=True, comment="Provider response JSON."),
|
||||
*_default_fields("human_input_v2_form_delivery_attempts"),
|
||||
sa.UniqueConstraint("endpoint_id", "attempt_number", name="hiv2_form_attempts_endpoint_number_uq"),
|
||||
comment="Append-oriented delivery attempts for Human Input v2 form endpoints.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_attempts_form_status_created_idx",
|
||||
"human_input_v2_form_delivery_attempts",
|
||||
["form_id", "status", "created_at", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_attempts_status_scheduled_idx",
|
||||
"human_input_v2_form_delivery_attempts",
|
||||
["status", "scheduled_at", "id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_v2_form_upload_tokens",
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column("app_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to apps.id."),
|
||||
sa.Column("form_id", models.types.StringUUID(), nullable=False, comment="Logical human_input_v2_forms.id."),
|
||||
sa.Column(
|
||||
"endpoint_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_v2_form_delivery_endpoints.id.",
|
||||
),
|
||||
sa.Column("upload_token_hash", sa.String(length=64), nullable=False, comment="Hashed upload capability."),
|
||||
*_default_fields("human_input_v2_form_upload_tokens"),
|
||||
sa.UniqueConstraint("upload_token_hash", name="hiv2_form_upload_tokens_hash_uq"),
|
||||
comment="Hashed endpoint-scoped upload capabilities for Human Input v2 forms.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_upload_tokens_form_endpoint_idx",
|
||||
"human_input_v2_form_upload_tokens",
|
||||
["form_id", "endpoint_id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_v2_form_upload_files",
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column("app_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to apps.id."),
|
||||
sa.Column("form_id", models.types.StringUUID(), nullable=False, comment="Logical human_input_v2_forms.id."),
|
||||
sa.Column(
|
||||
"endpoint_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_v2_form_delivery_endpoints.id.",
|
||||
),
|
||||
sa.Column("upload_file_id", models.types.StringUUID(), nullable=False, comment="Logical upload_files.id."),
|
||||
sa.Column(
|
||||
"upload_token_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_v2_form_upload_tokens.id.",
|
||||
),
|
||||
*_default_fields("human_input_v2_form_upload_files"),
|
||||
sa.UniqueConstraint("upload_file_id", name="hiv2_form_upload_files_file_uq"),
|
||||
comment="Durable Human Input v2 form, endpoint, upload-token, and file associations.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_upload_files_form_endpoint_idx",
|
||||
"human_input_v2_form_upload_files",
|
||||
["form_id", "endpoint_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_upload_files_token_idx",
|
||||
"human_input_v2_form_upload_files",
|
||||
["upload_token_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("human_input_v2_form_upload_files")
|
||||
op.drop_table("human_input_v2_form_upload_tokens")
|
||||
op.drop_table("human_input_v2_form_delivery_attempts")
|
||||
op.drop_table("human_input_v2_form_delivery_endpoints")
|
||||
op.drop_table("human_input_v2_form_approver_grants")
|
||||
op.drop_table("human_input_v2_forms")
|
||||
op.drop_table("human_input_email_providers")
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
"""add human input v2 otp proof session
|
||||
|
||||
Revision ID: 9c2e5f7a1b3d
|
||||
Revises: 8a1c4e7f9b2d
|
||||
Create Date: 2026-07-25 13:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "9c2e5f7a1b3d"
|
||||
down_revision = "8a1c4e7f9b2d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"human_input_v2_form_otp_challenges",
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column(
|
||||
"form_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical foreign key to human_input_v2_forms.id.",
|
||||
),
|
||||
sa.Column(
|
||||
"approver_grant_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical foreign key to human_input_v2_form_approver_grants.id.",
|
||||
),
|
||||
sa.Column("subject_type", sa.String(length=20), nullable=False, comment="OTP proof subject discriminator."),
|
||||
sa.Column(
|
||||
"challenge_token_hash",
|
||||
sa.String(length=64),
|
||||
nullable=False,
|
||||
comment="SHA-256 hash of the ephemeral challenge token.",
|
||||
),
|
||||
sa.Column(
|
||||
"code_hash",
|
||||
sa.String(length=255),
|
||||
nullable=False,
|
||||
comment="Slow password hash of the one-time verification code.",
|
||||
),
|
||||
sa.Column(
|
||||
"code_hash_algorithm",
|
||||
sa.String(length=50),
|
||||
nullable=False,
|
||||
comment="Verifier algorithm discriminator for code_hash.",
|
||||
),
|
||||
sa.Column("email_hash", sa.String(length=64), nullable=False, comment="SHA-256 of the normalized Email."),
|
||||
sa.Column("email", sa.String(length=320), nullable=False, comment="Normalized destination Email."),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, comment="Current proof-session usability."),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=False, comment="Challenge expiration timestamp."),
|
||||
sa.Column("resend_after", sa.DateTime(), nullable=False, comment="Earliest replacement timestamp."),
|
||||
sa.Column(
|
||||
"contact_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical human_input_contacts.id captured for a Contact incarnation.",
|
||||
),
|
||||
sa.Column("send_count", sa.Integer(), nullable=False, comment="One-based send count for the grant scope."),
|
||||
sa.Column("attempt_count", sa.Integer(), nullable=False, comment="Consumed verification attempts."),
|
||||
sa.Column("verified_at", sa.DateTime(), nullable=True, comment="Successful verification timestamp."),
|
||||
sa.Column("invalidated_at", sa.DateTime(), nullable=True, comment="Replacement or stale-identity timestamp."),
|
||||
sa.Column("id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name="human_input_v2_form_otp_challenges_pkey"),
|
||||
sa.UniqueConstraint("challenge_token_hash", name="hiv2_form_otp_challenges_token_uq"),
|
||||
sa.CheckConstraint(
|
||||
"(subject_type = 'contact' AND contact_id IS NOT NULL) OR "
|
||||
"(subject_type = 'email_address' AND contact_id IS NULL)",
|
||||
name="hiv2_form_otp_challenges_subject_identity_ck",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"send_count >= 1 AND send_count <= 5",
|
||||
name="hiv2_form_otp_challenges_send_count_ck",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"attempt_count >= 0 AND attempt_count <= 5",
|
||||
name="hiv2_form_otp_challenges_attempt_count_ck",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(status = 'verified' AND verified_at IS NOT NULL AND invalidated_at IS NULL) OR "
|
||||
"(status = 'invalidated' AND verified_at IS NULL AND invalidated_at IS NOT NULL) OR "
|
||||
"(status IN ('pending', 'expired') AND verified_at IS NULL AND invalidated_at IS NULL)",
|
||||
name="hiv2_form_otp_challenges_terminal_timestamps_ck",
|
||||
),
|
||||
comment="Hashed OTP proof sessions for Email-based Human Input v2 approval.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_otp_scope_created_idx",
|
||||
"human_input_v2_form_otp_challenges",
|
||||
["tenant_id", "form_id", "approver_grant_id", "created_at", "id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("human_input_v2_form_otp_challenges")
|
||||
-183
@@ -1,183 +0,0 @@
|
||||
"""add human input v2 submission runtime
|
||||
|
||||
Revision ID: ad4f6b8c2e1d
|
||||
Revises: 9c2e5f7a1b3d
|
||||
Create Date: 2026-07-25 14:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "ad4f6b8c2e1d"
|
||||
down_revision = "9c2e5f7a1b3d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _default_fields(table_name: str) -> tuple[sa.Column, sa.Column, sa.Column, sa.PrimaryKeyConstraint]:
|
||||
return (
|
||||
sa.Column("id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=f"{table_name}_pkey"),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"human_input_v2_form_audit_events",
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column(
|
||||
"form_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical foreign key to human_input_v2_forms.id.",
|
||||
),
|
||||
sa.Column("event_type", sa.String(length=64), nullable=False, comment="Stable append-only event name."),
|
||||
sa.Column("occurred_at", sa.DateTime(), nullable=False, comment="Business timestamp for the audited fact."),
|
||||
sa.Column(
|
||||
"approver_grant_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical foreign key to human_input_v2_form_approver_grants.id.",
|
||||
),
|
||||
sa.Column(
|
||||
"endpoint_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical foreign key to human_input_v2_form_delivery_endpoints.id.",
|
||||
),
|
||||
sa.Column("channel", sa.String(length=20), nullable=True, comment="Originating interaction channel."),
|
||||
sa.Column("reason_code", sa.String(length=100), nullable=True, comment="Stable rejection reason code."),
|
||||
sa.Column("reason_message", models.types.LongText(), nullable=True, comment="Operator-safe diagnostic detail."),
|
||||
sa.Column(
|
||||
"authorization_proof",
|
||||
models.types.LongText(),
|
||||
nullable=True,
|
||||
comment="Secret-free verified proof serialized as structured JSON text.",
|
||||
),
|
||||
sa.Column(
|
||||
"event_payload",
|
||||
models.types.LongText(),
|
||||
nullable=True,
|
||||
comment="Immutable event-specific structured JSON text.",
|
||||
),
|
||||
*_default_fields("human_input_v2_form_audit_events"),
|
||||
sa.CheckConstraint(
|
||||
"event_type <> 'submission_authorized' OR "
|
||||
"(approver_grant_id IS NOT NULL AND authorization_proof IS NOT NULL)",
|
||||
name="hiv2_form_audit_authorized_proof_ck",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"event_type <> 'submission_rejected' OR reason_code IS NOT NULL",
|
||||
name="hiv2_form_audit_rejection_reason_ck",
|
||||
),
|
||||
comment="Append-only Human Input v2 audit facts for proof sessions and submission authorization.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_audit_form_occurred_idx",
|
||||
"human_input_v2_form_audit_events",
|
||||
["form_id", "occurred_at", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_audit_tenant_occurred_idx",
|
||||
"human_input_v2_form_audit_events",
|
||||
["tenant_id", "occurred_at", "id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_v2_form_submissions",
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column(
|
||||
"form_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical foreign key to human_input_v2_forms.id.",
|
||||
),
|
||||
sa.Column(
|
||||
"approver_grant_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical foreign key to human_input_v2_form_approver_grants.id.",
|
||||
),
|
||||
sa.Column("actor_type", sa.String(length=20), nullable=False, comment="Submission actor discriminator."),
|
||||
sa.Column(
|
||||
"authorization_audit_event_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical foreign key to the authorized human_input_v2_form_audit_events.id.",
|
||||
),
|
||||
sa.Column(
|
||||
"selected_action_id",
|
||||
sa.String(length=200),
|
||||
nullable=False,
|
||||
comment="Selected action from the frozen form definition.",
|
||||
),
|
||||
sa.Column(
|
||||
"input_snapshot",
|
||||
models.types.LongText(),
|
||||
nullable=False,
|
||||
comment="Unvalidated request.inputs object serialized as structured JSON text.",
|
||||
),
|
||||
sa.Column(
|
||||
"canonical_values",
|
||||
models.types.LongText(),
|
||||
nullable=False,
|
||||
comment="Validated runtime values serialized as structured JSON text.",
|
||||
),
|
||||
sa.Column("submitted_at", sa.DateTime(), nullable=False, comment="Winning commit business timestamp."),
|
||||
sa.Column(
|
||||
"actor_account_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical accounts.id for an Account actor.",
|
||||
),
|
||||
sa.Column(
|
||||
"actor_end_user_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical end_users.id for an EndUser actor.",
|
||||
),
|
||||
sa.Column(
|
||||
"actor_normalized_email",
|
||||
sa.String(length=320),
|
||||
nullable=True,
|
||||
comment="Normalized EmailAddress actor identity.",
|
||||
),
|
||||
sa.Column(
|
||||
"endpoint_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical foreign key to human_input_v2_form_delivery_endpoints.id.",
|
||||
),
|
||||
*_default_fields("human_input_v2_form_submissions"),
|
||||
sa.UniqueConstraint("form_id", name="hiv2_form_submissions_form_uq"),
|
||||
sa.UniqueConstraint(
|
||||
"authorization_audit_event_id",
|
||||
name="hiv2_submission_authorization_audit_event_uq",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(actor_type = 'account' AND actor_account_id IS NOT NULL AND actor_end_user_id IS NULL "
|
||||
"AND actor_normalized_email IS NULL) OR "
|
||||
"(actor_type = 'end_user' AND actor_account_id IS NULL AND actor_end_user_id IS NOT NULL "
|
||||
"AND actor_normalized_email IS NULL) OR "
|
||||
"(actor_type = 'email_address' AND actor_account_id IS NULL AND actor_end_user_id IS NULL "
|
||||
"AND actor_normalized_email IS NOT NULL)",
|
||||
name="hiv2_form_submissions_actor_identity_ck",
|
||||
),
|
||||
comment="Immutable first successful Human Input v2 submission and business actor.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_submissions_tenant_submitted_idx",
|
||||
"human_input_v2_form_submissions",
|
||||
["tenant_id", "submitted_at", "id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("human_input_v2_form_submissions")
|
||||
op.drop_table("human_input_v2_form_audit_events")
|
||||
+12
-96
@@ -15,21 +15,22 @@ from .agent import (
|
||||
AgentConfigRevision,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentConfigSnapshot,
|
||||
AgentConfigVersionKind,
|
||||
AgentDebugConversation,
|
||||
AgentDriveFile,
|
||||
AgentDriveFileKind,
|
||||
AgentHomeSnapshot,
|
||||
AgentIconType,
|
||||
AgentKind,
|
||||
AgentRuntimeSession,
|
||||
AgentRuntimeSessionOwnerType,
|
||||
AgentRuntimeSessionStatus,
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
AgentStatus,
|
||||
AgentWorkingResourceStatus,
|
||||
AgentWorkspace,
|
||||
AgentWorkspaceBinding,
|
||||
AgentWorkspaceOwnerType,
|
||||
WorkflowAgentBindingType,
|
||||
WorkflowAgentNodeBinding,
|
||||
WorkflowAgentRuntimeSession,
|
||||
WorkflowAgentRuntimeSessionStatus,
|
||||
)
|
||||
from .api_based_extension import APIBasedExtension, APIBasedExtensionPoint
|
||||
from .comment import (
|
||||
@@ -66,50 +67,6 @@ from .enums import (
|
||||
)
|
||||
from .execution_extra_content import ExecutionExtraContent, HumanInputContent
|
||||
from .human_input import HumanInputForm, HumanInputFormUploadFile, HumanInputFormUploadToken
|
||||
from .human_input_v2 import (
|
||||
AccountSessionAuthorizationProof,
|
||||
DingTalkIMIntegrationEncryptedCredentials,
|
||||
EmailOTPAuthorizationProof,
|
||||
FeishuIMIntegrationEncryptedCredentials,
|
||||
FormApproverGrantMatchedSources,
|
||||
FormApproverGrantSubjectSnapshot,
|
||||
FormAuditEventPayload,
|
||||
FormAuthorizationProof,
|
||||
FormCanonicalValues,
|
||||
FormDeliveryProviderResponse,
|
||||
FormInputSnapshot,
|
||||
HumanInputContact,
|
||||
HumanInputContactIdentitySource,
|
||||
HumanInputEmailProvider,
|
||||
HumanInputIMBinding,
|
||||
HumanInputIMIdentity,
|
||||
HumanInputIMIntegration,
|
||||
HumanInputIMSyncResult,
|
||||
HumanInputIMSyncRun,
|
||||
HumanInputPlatformContactWorkspaceEntry,
|
||||
HumanInputV2Form,
|
||||
HumanInputV2FormApproverGrant,
|
||||
HumanInputV2FormAuditEvent,
|
||||
HumanInputV2FormDefinition,
|
||||
HumanInputV2FormDeliveryAttempt,
|
||||
HumanInputV2FormDeliveryEndpoint,
|
||||
HumanInputV2FormOTPChallenge,
|
||||
HumanInputV2FormSubmission,
|
||||
HumanInputV2FormUploadFile,
|
||||
HumanInputV2FormUploadToken,
|
||||
IMIdentityAuthorizationProof,
|
||||
IMIdentityRawPayload,
|
||||
IMIntegrationEncryptedCredentials,
|
||||
IMSyncContactSnapshot,
|
||||
IMSyncDirectoryEntryPayload,
|
||||
IMSyncIdentitySnapshot,
|
||||
LarkIMIntegrationEncryptedCredentials,
|
||||
MSTeamsIMIntegrationEncryptedCredentials,
|
||||
ResendEmailProviderEncryptedCredentials,
|
||||
SlackIMIntegrationEncryptedCredentials,
|
||||
TrustedEndUserAuthorizationProof,
|
||||
WeComIMIntegrationEncryptedCredentials,
|
||||
)
|
||||
from .model import (
|
||||
AccountTrialAppRecord,
|
||||
ApiRequest,
|
||||
@@ -199,7 +156,6 @@ __all__ = [
|
||||
"APIBasedExtensionPoint",
|
||||
"Account",
|
||||
"AccountIntegrate",
|
||||
"AccountSessionAuthorizationProof",
|
||||
"AccountStatus",
|
||||
"AccountStepByStepTourState",
|
||||
"AccountTrialAppRecord",
|
||||
@@ -209,17 +165,20 @@ __all__ = [
|
||||
"AgentConfigRevision",
|
||||
"AgentConfigRevisionOperation",
|
||||
"AgentConfigSnapshot",
|
||||
"AgentConfigVersionKind",
|
||||
"AgentDebugConversation",
|
||||
"AgentDriveFile",
|
||||
"AgentDriveFileKind",
|
||||
"AgentHomeSnapshot",
|
||||
"AgentIconType",
|
||||
"AgentKind",
|
||||
"AgentRuntimeSession",
|
||||
"AgentRuntimeSessionOwnerType",
|
||||
"AgentRuntimeSessionStatus",
|
||||
"AgentScope",
|
||||
"AgentSource",
|
||||
"AgentStatus",
|
||||
"AgentWorkingResourceStatus",
|
||||
"AgentWorkspace",
|
||||
"AgentWorkspaceBinding",
|
||||
"AgentWorkspaceOwnerType",
|
||||
"ApiRequest",
|
||||
"ApiToken",
|
||||
"ApiToolProvider",
|
||||
@@ -256,59 +215,22 @@ __all__ = [
|
||||
"DatasourceOauthParamConfig",
|
||||
"DatasourceProvider",
|
||||
"DifySetup",
|
||||
"DingTalkIMIntegrationEncryptedCredentials",
|
||||
"Document",
|
||||
"DocumentSegment",
|
||||
"EmailOTPAuthorizationProof",
|
||||
"Embedding",
|
||||
"EndUser",
|
||||
"ExecutionExtraContent",
|
||||
"ExporleBanner",
|
||||
"ExternalKnowledgeApis",
|
||||
"ExternalKnowledgeBindings",
|
||||
"FeishuIMIntegrationEncryptedCredentials",
|
||||
"FormApproverGrantMatchedSources",
|
||||
"FormApproverGrantSubjectSnapshot",
|
||||
"FormAuditEventPayload",
|
||||
"FormAuthorizationProof",
|
||||
"FormCanonicalValues",
|
||||
"FormDeliveryProviderResponse",
|
||||
"FormInputSnapshot",
|
||||
"HumanInputContact",
|
||||
"HumanInputContactIdentitySource",
|
||||
"HumanInputContent",
|
||||
"HumanInputEmailProvider",
|
||||
"HumanInputForm",
|
||||
"HumanInputFormUploadFile",
|
||||
"HumanInputFormUploadToken",
|
||||
"HumanInputIMBinding",
|
||||
"HumanInputIMIdentity",
|
||||
"HumanInputIMIntegration",
|
||||
"HumanInputIMSyncResult",
|
||||
"HumanInputIMSyncRun",
|
||||
"HumanInputPlatformContactWorkspaceEntry",
|
||||
"HumanInputV2Form",
|
||||
"HumanInputV2FormApproverGrant",
|
||||
"HumanInputV2FormAuditEvent",
|
||||
"HumanInputV2FormDefinition",
|
||||
"HumanInputV2FormDeliveryAttempt",
|
||||
"HumanInputV2FormDeliveryEndpoint",
|
||||
"HumanInputV2FormOTPChallenge",
|
||||
"HumanInputV2FormSubmission",
|
||||
"HumanInputV2FormUploadFile",
|
||||
"HumanInputV2FormUploadToken",
|
||||
"IMIdentityAuthorizationProof",
|
||||
"IMIdentityRawPayload",
|
||||
"IMIntegrationEncryptedCredentials",
|
||||
"IMSyncContactSnapshot",
|
||||
"IMSyncDirectoryEntryPayload",
|
||||
"IMSyncIdentitySnapshot",
|
||||
"IconType",
|
||||
"InstalledApp",
|
||||
"InvitationCode",
|
||||
"LarkIMIntegrationEncryptedCredentials",
|
||||
"LoadBalancingModelConfig",
|
||||
"MSTeamsIMIntegrationEncryptedCredentials",
|
||||
"Message",
|
||||
"MessageAgentThought",
|
||||
"MessageAnnotation",
|
||||
@@ -326,10 +248,8 @@ __all__ = [
|
||||
"ProviderQuotaType",
|
||||
"ProviderType",
|
||||
"RecommendedApp",
|
||||
"ResendEmailProviderEncryptedCredentials",
|
||||
"SavedMessage",
|
||||
"Site",
|
||||
"SlackIMIntegrationEncryptedCredentials",
|
||||
"SnippetType",
|
||||
"Tag",
|
||||
"TagBinding",
|
||||
@@ -350,15 +270,11 @@ __all__ = [
|
||||
"TriggerOAuthSystemClient",
|
||||
"TriggerOAuthTenantClient",
|
||||
"TriggerSubscription",
|
||||
"TrustedEndUserAuthorizationProof",
|
||||
"UploadFile",
|
||||
"WeComIMIntegrationEncryptedCredentials",
|
||||
"Whitelist",
|
||||
"Workflow",
|
||||
"WorkflowAgentBindingType",
|
||||
"WorkflowAgentNodeBinding",
|
||||
"WorkflowAgentRuntimeSession",
|
||||
"WorkflowAgentRuntimeSessionStatus",
|
||||
"WorkflowAppLog",
|
||||
"WorkflowAppLogCreatedFrom",
|
||||
"WorkflowArchiveLog",
|
||||
|
||||
+114
-104
@@ -116,35 +116,29 @@ class WorkflowAgentBindingType(StrEnum):
|
||||
INLINE_AGENT = "inline_agent"
|
||||
|
||||
|
||||
class AgentRuntimeSessionStatus(StrEnum):
|
||||
"""Lifecycle state of an Agent backend session snapshot.
|
||||
class AgentWorkingResourceStatus(StrEnum):
|
||||
"""Product lifecycle state for a persistent working-environment resource."""
|
||||
|
||||
Owner-agnostic: applies both to workflow Agent Node runs (owner =
|
||||
workflow_run) and to Agent App conversations (owner = conversation).
|
||||
"""
|
||||
|
||||
# Snapshot can be reused by a later Agent run in the same session.
|
||||
ACTIVE = "active"
|
||||
# Snapshot has been retired and must not be submitted to Agent backend again.
|
||||
CLEANED = "cleaned"
|
||||
RETIRED = "retired"
|
||||
|
||||
|
||||
class AgentRuntimeSessionOwnerType(StrEnum):
|
||||
"""Which product surface owns an Agent runtime session row."""
|
||||
class AgentWorkspaceOwnerType(StrEnum):
|
||||
"""Product scope that owns a Workspace."""
|
||||
|
||||
# Owned by one workflow Agent Node execution scope.
|
||||
WORKFLOW_RUN = "workflow_run"
|
||||
# Owned by one Agent App conversation (multi-turn chat).
|
||||
CONVERSATION = "conversation"
|
||||
BUILD_DRAFT = "build_draft"
|
||||
|
||||
|
||||
# Back-compat alias: the workflow lifecycle code (shipped in PR #36724) imports
|
||||
# the old name. Kept so unifying the table does not churn that path.
|
||||
WorkflowAgentRuntimeSessionStatus = AgentRuntimeSessionStatus
|
||||
class AgentConfigVersionKind(StrEnum):
|
||||
SNAPSHOT = "snapshot"
|
||||
DRAFT = "draft"
|
||||
BUILD_DRAFT = "build_draft"
|
||||
|
||||
|
||||
class Agent(DefaultFieldsMixin, Base):
|
||||
"""Workspace-scoped Agent identity used by Agent Roster and workflow-only agents."""
|
||||
"""Agent Soul and source lineage; ``AgentWorkspaceBinding.id`` identifies each materialized participant."""
|
||||
|
||||
__tablename__ = "agents"
|
||||
__table_args__ = (
|
||||
@@ -221,14 +215,42 @@ class Agent(DefaultFieldsMixin, Base):
|
||||
archived_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class AgentDebugConversation(DefaultFieldsMixin, Base):
|
||||
"""Per-account, per-draft console debug conversation for an Agent App.
|
||||
class AgentHomeSnapshot(Base):
|
||||
"""Append-only mapping from one Agent-owned Home identity to its backend ref.
|
||||
|
||||
Agent App preview state must be isolated by editor account. The Agent row is
|
||||
shared by everyone in the workspace, so this table owns the user-specific
|
||||
conversation pointers used by console debug chat. ``draft`` is the Preview
|
||||
conversation and ``debug_build`` is the Build conversation; they must never
|
||||
share persisted messages or runtime sessions.
|
||||
Product tables reference ``id``. ``snapshot_ref`` remains an opaque
|
||||
deployment-specific handle and is only consumed at Dify Agent boundaries.
|
||||
Snapshot bytes and ``snapshot_ref`` are immutable. Lifecycle metadata can
|
||||
transition ACTIVE -> RETIRED; successful physical collection deletes row.
|
||||
"""
|
||||
|
||||
__tablename__ = "agent_home_snapshots"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="agent_home_snapshot_pkey"),
|
||||
Index("agent_home_snapshot_tenant_agent_idx", "tenant_id", "agent_id"),
|
||||
Index("agent_home_snapshot_status_retired_idx", "status", "retired_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuidv7()))
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
snapshot_ref: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status: Mapped[AgentWorkingResourceStatus] = mapped_column(
|
||||
EnumText(AgentWorkingResourceStatus, length=32),
|
||||
nullable=False,
|
||||
default=AgentWorkingResourceStatus.ACTIVE,
|
||||
server_default=AgentWorkingResourceStatus.ACTIVE.value,
|
||||
)
|
||||
retired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp())
|
||||
|
||||
|
||||
class AgentDebugConversation(DefaultFieldsMixin, Base):
|
||||
"""Current console Conversation pointer for one account and draft surface.
|
||||
|
||||
This row owns no Binding or runtime. A Preview Conversation holds its
|
||||
CONVERSATION Binding pointer, while a DEBUG_BUILD AgentConfigDraft holds its
|
||||
BUILD_DRAFT Binding pointer.
|
||||
"""
|
||||
|
||||
__tablename__ = "agent_debug_conversations"
|
||||
@@ -259,7 +281,11 @@ class AgentDebugConversation(DefaultFieldsMixin, Base):
|
||||
|
||||
|
||||
class AgentConfigDraft(DefaultFieldsMixin, Base):
|
||||
"""Editable Agent Soul draft separated from immutable published snapshots."""
|
||||
"""Editable Agent Soul draft separated from immutable published snapshots.
|
||||
|
||||
A DEBUG_BUILD draft owns its materialized participant through
|
||||
``agent_workspace_binding_id``. Normal drafts leave that pointer unset.
|
||||
"""
|
||||
|
||||
__tablename__ = "agent_config_drafts"
|
||||
__table_args__ = (
|
||||
@@ -281,6 +307,8 @@ class AgentConfigDraft(DefaultFieldsMixin, Base):
|
||||
account_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
draft_owner_key: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
base_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
home_snapshot_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
agent_workspace_binding_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
config_snapshot: Mapped[Any] = mapped_column(JSONModelColumn(AgentSoulConfig), nullable=False)
|
||||
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
@@ -316,6 +344,7 @@ class AgentConfigSnapshot(DefaultFieldsMixin, Base):
|
||||
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
version: Mapped[int] = mapped_column(sa.Integer, nullable=False)
|
||||
config_snapshot: Mapped[Any] = mapped_column(JSONModelColumn(AgentSoulConfig), nullable=False)
|
||||
home_snapshot_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
summary: Mapped[str | None] = mapped_column(LongText, nullable=True)
|
||||
version_note: Mapped[str | None] = mapped_column(LongText, nullable=True)
|
||||
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
@@ -432,102 +461,83 @@ class WorkflowAgentNodeBinding(DefaultFieldsMixin, Base):
|
||||
return dict(self.node_job_config)
|
||||
|
||||
|
||||
class AgentRuntimeSession(DefaultFieldsMixin, Base):
|
||||
"""Persisted Agent backend session snapshot, owner-agnostic.
|
||||
class AgentWorkspace(DefaultFieldsMixin, Base):
|
||||
"""Mutable Workspace owned by one product scope, independent of Agents."""
|
||||
|
||||
One unified table serves both owners (decision Q2):
|
||||
- workflow Agent Node runs: ``owner_type = workflow_run``; the
|
||||
``workflow_id / workflow_run_id / node_id / binding_id /
|
||||
agent_config_snapshot_id / composition_layer_specs`` columns are set.
|
||||
- Agent App conversations: ``owner_type = conversation``; the
|
||||
``conversation_id`` column is set and the workflow columns stay NULL.
|
||||
Runtime state is scoped by ``agent_config_snapshot_id``. For published
|
||||
web/API runs this points to an immutable AgentConfigSnapshot; for console
|
||||
debugger/build runs it points to the editable AgentConfigDraft row.
|
||||
|
||||
The snapshot is runtime state returned by Agent backend, kept separate from
|
||||
Agent Soul snapshots and workflow node-job config.
|
||||
"""
|
||||
|
||||
__tablename__ = "agent_runtime_sessions"
|
||||
__tablename__ = "agent_workspaces"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="agent_runtime_session_pkey"),
|
||||
# Workflow owner uniqueness (partial: only rows with a workflow_run_id).
|
||||
sa.PrimaryKeyConstraint("id", name="agent_workspace_pkey"),
|
||||
Index(
|
||||
"agent_runtime_session_workflow_scope_unique",
|
||||
"agent_workspace_owner_active_unique",
|
||||
"tenant_id",
|
||||
"workflow_run_id",
|
||||
"node_id",
|
||||
"binding_id",
|
||||
"agent_id",
|
||||
"owner_type",
|
||||
"owner_id",
|
||||
"owner_scope_key",
|
||||
"active_guard",
|
||||
unique=True,
|
||||
postgresql_where=sa.text("workflow_run_id IS NOT NULL"),
|
||||
),
|
||||
# Conversation owner uniqueness (partial: only rows with a conversation_id).
|
||||
Index(
|
||||
"agent_runtime_session_conversation_scope_unique",
|
||||
"tenant_id",
|
||||
"conversation_id",
|
||||
"agent_id",
|
||||
"agent_config_snapshot_id",
|
||||
unique=True,
|
||||
postgresql_where=sa.text("conversation_id IS NOT NULL"),
|
||||
),
|
||||
Index(
|
||||
"agent_runtime_session_workflow_lookup_idx",
|
||||
"tenant_id",
|
||||
"workflow_run_id",
|
||||
"node_id",
|
||||
"status",
|
||||
),
|
||||
Index(
|
||||
"agent_runtime_session_conversation_lookup_idx",
|
||||
"tenant_id",
|
||||
"conversation_id",
|
||||
"status",
|
||||
),
|
||||
Index("agent_runtime_session_backend_run_idx", "backend_run_id"),
|
||||
Index("agent_workspace_tenant_status_idx", "tenant_id", "status"),
|
||||
Index("agent_workspace_tenant_app_status_idx", "tenant_id", "app_id", "status"),
|
||||
Index("agent_workspace_status_retired_idx", "status", "retired_at"),
|
||||
)
|
||||
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
owner_type: Mapped[AgentRuntimeSessionOwnerType] = mapped_column(
|
||||
EnumText(AgentRuntimeSessionOwnerType, length=32), nullable=False
|
||||
owner_type: Mapped[AgentWorkspaceOwnerType] = mapped_column(
|
||||
EnumText(AgentWorkspaceOwnerType, length=32), nullable=False
|
||||
)
|
||||
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
backend_run_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
session_snapshot: Mapped[str] = mapped_column(LongText, nullable=False)
|
||||
# Workflow-owner columns (NULL for conversation owner).
|
||||
workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
workflow_run_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
node_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
node_execution_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
binding_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
agent_config_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
# JSON-encoded list of non-sensitive runtime layer specs ({name, type, deps,
|
||||
# config}). The persisted schema keeps its original name because the sandbox
|
||||
# refactor intentionally avoids a storage migration.
|
||||
composition_layer_specs: Mapped[str] = mapped_column(LongText, nullable=False, server_default="[]")
|
||||
# Conversation-owner column (NULL for workflow owner).
|
||||
conversation_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
status: Mapped[AgentRuntimeSessionStatus] = mapped_column(
|
||||
EnumText(AgentRuntimeSessionStatus, length=32),
|
||||
owner_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
owner_scope_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
backend_workspace_ref: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status: Mapped[AgentWorkingResourceStatus] = mapped_column(
|
||||
EnumText(AgentWorkingResourceStatus, length=32),
|
||||
nullable=False,
|
||||
default=AgentRuntimeSessionStatus.ACTIVE,
|
||||
default=AgentWorkingResourceStatus.ACTIVE,
|
||||
server_default=AgentWorkingResourceStatus.ACTIVE.value,
|
||||
)
|
||||
cleaned_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
# ENG-637: when a run pauses for a dify.ask_human deferred call, these link
|
||||
# the session to the awaiting HITL form and the deferred tool_call_id, so a
|
||||
# resumed node can map the submitted form back into deferred_tool_results.
|
||||
# Both NULL whenever the session is not paused on human input.
|
||||
active_guard: Mapped[int | None] = mapped_column(sa.SmallInteger, nullable=True, default=1, server_default="1")
|
||||
retired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class AgentWorkspaceBinding(DefaultFieldsMixin, Base):
|
||||
"""One materialized Agent participant and session attached to a Workspace.
|
||||
|
||||
All resource IDs are logical associations rather than database foreign
|
||||
keys, so RETIRED rows can outlive their Workspace or base Home Snapshot.
|
||||
``agent_id`` identifies the source Agent Soul; this row's ``id`` identifies
|
||||
the participant and its private Materialized Home.
|
||||
"""
|
||||
|
||||
__tablename__ = "agent_workspace_bindings"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="agent_workspace_binding_pkey"),
|
||||
Index("agent_workspace_binding_workspace_status_idx", "tenant_id", "workspace_id", "status"),
|
||||
Index("agent_workspace_binding_agent_status_idx", "tenant_id", "agent_id", "status"),
|
||||
Index("agent_workspace_binding_status_retired_idx", "status", "retired_at"),
|
||||
)
|
||||
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
workspace_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
base_home_snapshot_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
agent_config_version_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
agent_config_version_kind: Mapped[AgentConfigVersionKind] = mapped_column(
|
||||
EnumText(AgentConfigVersionKind, length=32), nullable=False
|
||||
)
|
||||
backend_binding_ref: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
session_snapshot: Mapped[str | None] = mapped_column(LongText, nullable=True)
|
||||
status: Mapped[AgentWorkingResourceStatus] = mapped_column(
|
||||
EnumText(AgentWorkingResourceStatus, length=32),
|
||||
nullable=False,
|
||||
default=AgentWorkingResourceStatus.ACTIVE,
|
||||
server_default=AgentWorkingResourceStatus.ACTIVE.value,
|
||||
)
|
||||
retired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
pending_form_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
pending_tool_call_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
|
||||
# Back-compat alias for the shipped workflow lifecycle code (PR #36724).
|
||||
WorkflowAgentRuntimeSession = AgentRuntimeSession
|
||||
|
||||
|
||||
class AgentDriveFileKind(StrEnum):
|
||||
"""Kind of existing file record an agent-drive KV entry points at."""
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user