Compare commits

..
Author SHA1 Message Date
盐粒 YanliGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
a048f35099 refactor: introduce agent working environment architecture (#39480)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 13:16:05 +00:00
Yunlu WenandGitHub 22cef67804 chore(deps): bump gitpython from 3.1.54 to 3.1.57 in /api (#39684) 2026-07-28 10:27:16 +00:00
JoelandGitHub 3bc8c69def feat: label agent conversation reset action on hover (#39691) 2026-07-28 10:06:46 +00:00
yyhGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
798e5ed7a7 refactor(web): derive workspace billing from current workspace (#39674)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 09:32:03 +00:00
Escape0707andGitHub eef709e475 ci: enforce strict checks for unit tests (#39682) 2026-07-28 09:10:02 +00:00
Escape0707andGitHub 04158ac8ea ci: replace custom noqa markers with guard ignores (#39679) 2026-07-28 08:52:12 +00:00
JoelandGitHub ae0b66311d feat: add missing plugin installation to agent DSL imports (#39680) 2026-07-28 08:23:42 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>yunlu.wen
b97abe5328 chore: bump nltk from 3.9.4 to 3.10.0 in /api (#39503)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai>
2026-07-28 08:19:15 +00:00
JyongandGitHub d94314627f ci: wait for KnowledgeFS before deployment (#39677) 2026-07-28 07:12:29 +00:00
ShaktiandGitHub 7ec6a57ddf fix: populate completion_params from model schema defaults in Agent node (#39590) 2026-07-28 07:06:49 +00:00
JoelandGitHub 35b539e35b fix: improve unconfigured agent guidance (#39676) 2026-07-28 07:02:49 +00:00
Asuka MinatoGitHubByron.wangautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
3b040e6bed test: use sqlite3 session in test_web_login (#38725)
Co-authored-by: Byron.wang <byron@dify.ai>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 06:37:45 +00:00
Asuka MinatoandGitHub 9300be03f0 test: use sqlite3 session in test_agent_app_feature_service (#38724) 2026-07-28 06:32:07 +00:00
github-actions[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>yyh
137d4f3f60 chore(i18n): sync translations with en-US (#39671)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-07-28 06:11:36 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
e723b348cf test: use SQLite sessions in core app apps (#39103)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 05:54:17 +00:00
59fb603ec6 fix: reject trailing newlines in alphanumeric() validator (#39666) (#39667)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
2026-07-28 05:49:19 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
63f072ebfb test: use SQLite sessions in core rag (#39105)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 05:47:22 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
0913d04d33 test: use SQLite sessions in services retention (#39074)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 05:47:01 +00:00
Asuka MinatoandGitHub 80ff108fc0 test: use SQLite in Aliyun trace utilities (#38983) 2026-07-28 05:40:27 +00:00
Asuka MinatoandGitHub ea58129ebe test: move workflow app service coverage to unit tests (#38946) 2026-07-28 05:34:59 +00:00
-LAN-andGitHub 698869460c chore(docker): remove redundant feature preview env (#39035) 2026-07-28 05:34:35 +00:00
Asuka MinatoandGitHub f3f2f63110 test: use SQLite in LangSmith traces (#38986) 2026-07-28 05:26:52 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
b100cdc382 test: use SQLite sessions in core app (#39099)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 05:22:47 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
9e90b32991 test: use SQLite sessions in core datasource (#39104)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 05:22:18 +00:00
非法操作andGitHub b2d54cb2e9 chore: improve archived logs hint in logs list page (#39659) 2026-07-28 05:15:35 +00:00
yyhandGitHub 1e5e47b889 fix: validate plugin installation scope (#39669) 2026-07-28 04:13:20 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Byron.wang
dd28b0d165 test: move OAuth server service coverage to unit tests (#38931)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Byron.wang <byron@dify.ai>
2026-07-28 04:09:32 +00:00
JoelandGitHub 49e74e2f58 fix: prevent the model selector footer from covering options (#39668) 2026-07-28 04:02:28 +00:00
e6e5d761c2 fix: prevent Safari from clipping the settings close button (#39664)
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-07-28 04:01:12 +00:00
Asuka MinatoandGitHub e1ce808567 test: move data source controller coverage to unit tests (#38924) 2026-07-28 03:51:22 +00:00
Asuka MinatoandGitHub 3c7ad816d9 test: use SQLite sessions in commands (#39080) 2026-07-28 03:29:02 +00:00
Asuka MinatoandGitHub f44dd343da test: use sqlite3 session in test_plugin_service (#38727) 2026-07-28 03:27:58 +00:00
Asuka MinatoandGitHub 2d9b2d50f3 test: use sqlite3 session in test_wraps (#38770) 2026-07-28 03:27:22 +00:00
Asuka MinatoandGitHub 003e0f9614 test: move message cleanup coverage to unit tests (#38932) 2026-07-28 03:26:43 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Byron.wang
da0979b373 test: use SQLite sessions in services core (#39090)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Byron.wang <byron@dify.ai>
2026-07-28 03:20:27 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Byron.wang
81fb57639e test: use SQLite sessions in controllers service api (#39098)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Byron.wang <byron@dify.ai>
2026-07-28 03:19:17 +00:00
Asuka MinatoandGitHub 1e61078e93 test: use SQLite sessions in services core (#39088) 2026-07-28 03:18:32 +00:00
Escape0707andGitHub b597bb1b17 test: separate human input unit and database paths (#39655) 2026-07-28 03:18:01 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Byron.wang
65ead05dfc test: use SQLite sessions in services plugin (#39084)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Byron.wang <byron@dify.ai>
2026-07-28 03:16:19 +00:00
6f8ed69ee1 fix: fix mcp output_schema is optional (#39453)
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai>
2026-07-28 02:31:18 +00:00
Yunlu WenGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
59b879d2df chore: bump version to 1.16.1 (#39653)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 01:09:25 +00:00
Xiyuan ChenandGitHub a57b0b9b58 feat(plugin): allow disabling the tenant plugin model providers cache (#39632) 2026-07-27 23:53:59 +00:00
Xiyuan ChenandGitHub d8506efed6 fix(cli): decouple release script tests from the live compat window (#39658) 2026-07-27 23:50:05 +00:00
JingyiandGitHub b25b28cc76 fix(workflow): align block icon vector sizes (#39657) 2026-07-27 23:43:03 +00:00
yyhGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
f68cabe226 fix(web): align tour trigger DOM order (#39654)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-27 13:00:16 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Byron.wang
f73f83c5d6 test: use SQLite sessions in core app (#39075)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Byron.wang <byron@dify.ai>
2026-07-27 10:45:16 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Byron.wang
29801d6a65 test: use SQLite sessions in core tools (#39077)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Byron.wang <byron@dify.ai>
2026-07-27 10:45:11 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Byron.wang
75016e8bfe test: use SQLite sessions in core ops (#39072)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Byron.wang <byron@dify.ai>
2026-07-27 10:41:17 +00:00
yyhandGitHub b6dea7b2ba fix(workflow): preserve latest collaboration session (#39646) 2026-07-27 10:37:45 +00:00
yyhandGitHub b2b1cd7e97 chore: update workspace dependencies (#39641) 2026-07-27 10:27:31 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Byron.wang
ca63e01d45 test: use SQLite sessions in controllers service api (#39069)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Byron.wang <byron@dify.ai>
2026-07-27 10:26:23 +00:00
yyhandGitHub 271b6e1f5c fix(ui): unify combobox trigger focus rings (#39643) 2026-07-27 10:21:32 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Byron.wang
609ddf9e01 test: use SQLite sessions in core memory (#39066)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Byron.wang <byron@dify.ai>
2026-07-27 10:15:08 +00:00
818 changed files with 26372 additions and 73151 deletions
+43
View File
@@ -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:
+1
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
-12
View File
@@ -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",
]
+30 -76
View File
@@ -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",
]
+2 -3
View File
@@ -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,
)
+6
View File
@@ -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,
-2
View File
@@ -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",
]
+1 -1
View File
@@ -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:
-4
View File
@@ -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",
+7 -29
View File
@@ -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:
+18 -15
View File
@@ -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:
+14 -2
View File
@@ -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()
+2 -2
View File
@@ -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):
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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()
-2
View File
@@ -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.")
-2
View File
@@ -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()
+14 -2
View File
@@ -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())
)
+137 -68
View File
@@ -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
+45 -143
View File
@@ -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,
+130 -209
View File
@@ -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"]
+14 -3
View File
@@ -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)
+3 -3
View File
@@ -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)
+3 -10
View File
@@ -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)
+19 -4
View File
@@ -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)
View File
@@ -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")
-261
View File
@@ -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}
-134
View File
@@ -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,
)
-460
View File
@@ -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)
-81
View File
@@ -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."""
...
-197
View File
@@ -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",
]
-173
View File
@@ -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")
+38 -23
View File
@@ -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,
+2
View File
@@ -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():
+2
View File
@@ -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."""
+4 -80
View File
@@ -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"]
+2 -2
View File
@@ -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,
+149 -155
View File
@@ -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())
+227 -146
View File
@@ -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
+1
View File
@@ -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:
"""
+1 -1
View File
@@ -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]):
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
-12
View File
@@ -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.")
-5
View File
@@ -1,5 +0,0 @@
from typing import Annotated
from pydantic import Field
Timestamp = Annotated[int, Field(..., description="Unix timestamp in milliseconds")]
+5 -1
View File
@@ -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")
@@ -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 ###
@@ -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 ###
@@ -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")
@@ -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")
@@ -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")
@@ -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
View File
@@ -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
View File
@@ -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