Compare commits

..
Author SHA1 Message Date
JzoNg 5c8cdb4ca1 Merge branch 'main' into jzh 2026-07-27 17:56:29 +08:00
JzoNg dfa2859ea3 feat(contacts): confirm platform contact upgrades 2026-07-27 17:53:22 +08:00
JzoNg a68fca6f68 feat(members): confirm external contact upgrades 2026-07-27 17:39:44 +08:00
JzoNg f7a14cb99f chore: merge main into jzh 2026-07-27 16:41:26 +08:00
JzoNg 02f9e1b7ad merge main 2026-07-27 14:55:37 +08:00
JzoNg 5350700e47 feat(workflow): refresh Human Input migration UI 2026-07-27 14:22:59 +08:00
JzoNg 67a31db615 Merge branch 'main' into jzh 2026-07-27 13:39:32 +08:00
JzoNg ec3ff5cc01 chore(merge): merge main 2026-07-27 10:31:26 +08:00
QuantumGhost 7c2c319538 Add IM contact sync API change proposal 2026-07-27 02:44:47 +08:00
QuantumGhost 3815aac382 fix(api): keep IM delivery independent from email feature checks 2026-07-27 02:26:37 +08:00
QuantumGhost 4136705d97 fix(api): isolate human input delivery provider failures 2026-07-27 02:26:37 +08:00
QuantumGhost 14b740fc60 feat(api): dispatch human input forms through delivery providers 2026-07-27 02:26:37 +08:00
QuantumGhost 763d1695f2 fix(api): harden human input delivery payload handling 2026-07-27 02:25:57 +08:00
QuantumGhost bcefa8491d feat(api): add IM-ready human input delivery contract 2026-07-27 02:25:57 +08:00
QuantumGhost f89bcd0496 test(api): cover human input form delivery providers 2026-07-27 02:25:07 +08:00
QuantumGhost 08322dc8c2 feat(api): introduce human input form delivery providers 2026-07-27 02:24:17 +08:00
QuantumGhost fab7a0af7b Merge remote-tracking branch 'upstream/feat/hitl-im-dev' into feat/hitl-im-dev 2026-07-27 02:14:51 +08:00
QuantumGhost ce6a3787de test(api): strengthen contact directory branch coverage 2026-07-25 13:04:03 +08:00
QuantumGhost a277a0dc4c docs(openspec): archive human input v2 submission runtime 2026-07-25 12:41:51 +08:00
QuantumGhost f87b1f4693 fix(api): retry serialized submissions safely 2026-07-25 12:35:46 +08:00
QuantumGhost b3475ed624 fix(api): harden human input v2 submission invariants 2026-07-25 11:57:48 +08:00
QuantumGhost 92c3600fc4 feat: implement human input v2 submission runtime 2026-07-25 11:14:08 +08:00
QuantumGhost 363a7834aa docs(openspec): archive human input v2 OTP proof session 2026-07-25 10:08:56 +08:00
QuantumGhost be54c54a5d fix(api): tighten OTP proof session invariants 2026-07-25 10:05:00 +08:00
QuantumGhost 6f3d09009d feat(api): implement human input v2 OTP proof sessions 2026-07-25 09:36:52 +08:00
QuantumGhost d6583084ec docs(openspec): archive human input v2 form core 2026-07-25 08:35:00 +08:00
QuantumGhost 993ccfc7d8 fix(api): tighten human input v2 form invariants 2026-07-25 08:30:58 +08:00
QuantumGhost 8abbdec4fd feat(api): implement human input v2 form core 2026-07-25 08:07:08 +08:00
QuantumGhost 64dba42bf3 docs(openspec): archive human input v2 recipient resolution 2026-07-25 07:16:24 +08:00
QuantumGhost a3ded70600 fix(api): harden recipient resolution fallbacks 2026-07-25 07:13:43 +08:00
QuantumGhost fdde47bbc3 feat(api): implement human input v2 recipient resolution 2026-07-25 06:56:33 +08:00
QuantumGhost c16bbe786f docs(openspec): archive human input v2 IM control plane 2026-07-25 06:24:41 +08:00
QuantumGhost ab816a54aa fix(api): align IM sync contact semantics 2026-07-25 06:22:03 +08:00
QuantumGhost 2d3ba7fa5d fix(api): harden IM control plane invariants 2026-07-25 06:10:34 +08:00
QuantumGhost 7c5fc2a133 feat(api): implement human input v2 im control plane 2026-07-25 05:39:30 +08:00
QuantumGhost cb9ae0c0a6 docs(openspec): normalize contact directory spec 2026-07-25 04:48:35 +08:00
QuantumGhost ad31e11320 docs(openspec): archive human input v2 contact directory 2026-07-25 04:47:17 +08:00
QuantumGhost eedb4d29ea fix(api): scope contact directory deployment locking 2026-07-25 04:43:41 +08:00
QuantumGhost 4639e4b818 fix(api): serialize contact email claims 2026-07-25 04:24:04 +08:00
QuantumGhost 95e73dd4b1 fix(api): tighten contact directory invariants 2026-07-25 04:01:34 +08:00
QuantumGhost 05e2f1ddef fix(api): keep legacy human input version typing compatible 2026-07-25 03:28:29 +08:00
QuantumGhost 4ef0c50a97 feat(api): implement human input v2 contact directory 2026-07-25 03:26:43 +08:00
QuantumGhost 6c2ac9cd91 docs(openspec): split human input v2 domain core 2026-07-25 02:54:52 +08:00
QuantumGhost 318fbcef27 chore: update docs 2026-07-25 02:31:14 +08:00
QuantumGhost 4db897abc1 feat(api): add independent HITL v2 form models
Assisted-by: Codex
2026-07-25 02:03:08 +08:00
QuantumGhost 5f78012b7d docs(openspec): define human input v2 domain core
Assisted-by: Codex
2026-07-24 14:48:50 +08:00
JzoNg eed57c9abd Merge branch 'main' into jzh 2026-07-24 14:30:24 +08:00
QuantumGhost 179351d1fe Merge remote-tracking branch 'upstream/feat/hitl-im-dev' into feat/hitl-im-dev 2026-07-24 14:27:25 +08:00
JzoNg f1f20384eb Merge branch 'main' into jzh 2026-07-24 14:03:19 +08:00
QuantumGhost 4b2ceb1bf6 Merge remote-tracking branch 'upstream/feat/hitl-im-dev' into feat/hitl-im-dev 2026-07-24 11:56:21 +08:00
QuantumGhost eda9315a7b Merge remote-tracking branch 'upstream/feat/hitl-im-dev' into feat/hitl-im-dev 2026-07-24 11:55:46 +08:00
JzoNg 3a848a7a9c chore(merge): merge main 2026-07-24 11:25:45 +08:00
QuantumGhost 11b0723a92 chore: remove domain model stub 2026-07-24 11:03:11 +08:00
QuantumGhost f6440f6af4 update api stub, design spec and db models 2026-07-24 10:43:51 +08:00
QuantumGhost 462af76989 test(models): cover strict StrEnum JSON restoration
Assisted-by: Codex:gpt-5.6-sol
2026-07-24 05:03:22 +08:00
QuantumGhost 4b17446ed6 docs(models): clarify JSONModelColumn migration risks
Explain why legacy callers and stored data require compatibility review before adopting FrozenPydanticModelColumn.

Assisted-by: Codex
2026-07-23 15:33:48 +08:00
QuantumGhost 252054f9b6 fix(models): persist frozen Pydantic columns as text
Rename the strict immutable column type, deprecate JSONModelColumn, and cover single-encoded JSON storage.

Assisted-by: Codex
2026-07-23 15:29:21 +08:00
JzoNg 4193deb220 chore(merge): merge main 2026-07-23 15:26:49 +08:00
QuantumGhost 075d44a7fa refactor(models): split Pydantic JSON constructors
Assisted-by: Codex
2026-07-23 15:07:43 +08:00
QuantumGhost 5e696c9131 feat(models): add Pydantic model JSON type
Assisted-by: Codex
2026-07-23 14:40:43 +08:00
JzoNg 5f1646c14b feat(human-input): add batch migration and contact lookup boundaries 2026-07-23 11:09:29 +08:00
JzoNg 30b04d4d14 Merge branch 'main' into jzh 2026-07-23 09:59:09 +08:00
QuantumGhost e592c9f3ac docs: remove python stub from api summary docs 2026-07-23 09:20:59 +08:00
QuantumGhost fa5a2ff899 chore(api): move shared enum definitions to core/human_input_v2 directory 2026-07-23 09:20:25 +08:00
JzoNg 45d6efd8ab Merge branch 'main' into jzh 2026-07-23 07:29:26 +08:00
JzoNg d6b8466bcd refactor(contacts): simplify repository boundary 2026-07-22 18:04:12 +08:00
JzoNg 7fc5e60007 test(contacts): cover backend-aligned list contract 2026-07-22 17:42:36 +08:00
JzoNg 4e4f34be1f refactor(contacts): align management with list contract 2026-07-22 17:42:21 +08:00
QuantumGhost 09076f6555 docs: update spec and api stub 2026-07-22 17:02:23 +08:00
JzoNg dfcf690d9a feat(contacts): refine platform contact management 2026-07-22 16:37:24 +08:00
JzoNg ad1fe871f4 docs(openspec): clarify platform contact management 2026-07-22 16:37:08 +08:00
JzoNg f587807123 feat(contacts): add configured channel actions 2026-07-22 15:59:04 +08:00
JzoNg 6154e58da7 feat(contacts): add channels and email configuration 2026-07-22 15:52:45 +08:00
JzoNg 85d3226175 docs(openspec): update channels management requirements 2026-07-22 15:37:00 +08:00
QuantumGhost 47f20c8f3d chore: update openspec docs 2026-07-22 15:20:57 +08:00
JzoNg 074c172f52 chore(merge): resolve main branch conflicts 2026-07-22 14:54:53 +08:00
JzoNg 9986ff92b6 Merge branch 'main' into jzh 2026-07-21 17:02:03 +08:00
QuantumGhost ce694a20b4 Merge remote-tracking branch 'upstream/feat/hitl-im-dev' into feat/hitl-im-dev 2026-07-21 16:44:04 +08:00
QuantumGhost ef5933a90e chore(api): refine HITL v2 docs and API stubs 2026-07-21 16:12:35 +08:00
JzoNg ce66f7505c Merge branch 'main' into jzh 2026-07-20 17:35:46 +08:00
JzoNg 41024954b7 fix(human-input): rename recipients spec field 2026-07-20 17:29:38 +08:00
JzoNg 1af6a1803a Merge branch 'main' into jzh 2026-07-20 16:56:11 +08:00
JzoNg 446f0f993a chore(merge): merge main into jzh 2026-07-20 15:40:12 +08:00
JzoNg 3c88536e2a chore(openspec): complete Human Input v2 form tasks 2026-07-20 15:32:17 +08:00
JzoNg 34b71467e7 feat(file-uploader): add Human Input v2 upload routing 2026-07-20 15:32:03 +08:00
JzoNg 7f2e40225c feat(human-input-v2): add OTP-guarded public form route 2026-07-20 15:31:39 +08:00
JzoNg 33b0bc578f feat(human-input-v2): add mock-first transport contracts 2026-07-20 15:31:14 +08:00
JzoNg aa128184f8 refactor(human-input): extract shared public form presentation 2026-07-20 15:30:44 +08:00
JzoNg 47f6cac432 chore(merge): merge main into jzh 2026-07-20 14:43:52 +08:00
JzoNg 1c9d15116c chore(openspec): complete Human Input v2 migration tasks 2026-07-20 14:31:02 +08:00
JzoNg 679d011d17 feat(workflow): add Human Input v2 migration flow 2026-07-20 14:28:27 +08:00
JzoNg 1d4b5eba35 feat(workflow): gate Human Input v2 creation 2026-07-20 14:05:27 +08:00
JzoNg ad328d156a feat(workflow): add Human Input v2 migration planner 2026-07-20 13:57:15 +08:00
JzoNg 1e5c206d31 docs(openspec): propose Human Input v2 form page 2026-07-20 13:43:39 +08:00
JzoNg 8b66eedaba docs(openspec): propose Human Input v2 migration UI 2026-07-20 11:52:40 +08:00
JzoNg ea7465b853 merge main 2026-07-20 10:16:45 +08:00
QuantumGhost c5d19039c4 Merge remote-tracking branch 'upstream/feat/hitl-im-dev' into feat/hitl-im-dev 2026-07-19 11:33:39 +08:00
JzoNg 9cc072ed5d docs(openspec): complete Human Input v2 UI tasks 2026-07-18 14:07:15 +08:00
JzoNg 0c3e3f6f78 feat(workflow): integrate Human Input v2 workflow utilities 2026-07-18 14:06:53 +08:00
JzoNg 922565f5e3 feat(workflow): add Human Input v2 configuration UI 2026-07-18 14:06:38 +08:00
JzoNg 90487f57fc refactor(workflow): share Human Input editor infrastructure 2026-07-18 14:06:25 +08:00
JzoNg cee50acbc4 docs(openspec): document Human Input v2 UI acceptance 2026-07-18 14:04:49 +08:00
JzoNg 84fd4011ab Merge branch 'main' into jzh 2026-07-18 12:55:28 +08:00
JzoNg ef5f698746 Merge branch 'main' into jzh 2026-07-17 16:41:08 +08:00
JzoNg 2603ca1bfb feat(contacts): add mock-backed contacts management UI 2026-07-17 16:33:32 +08:00
QuantumGhost 9791c0891d chore: update frontend api stubs 2026-07-17 15:24:51 +08:00
JzoNg 93a745c9ca chore(contacts): complete IM platform frontend quality checks 2026-07-17 15:17:30 +08:00
JzoNg 78980e9f1f feat(contacts): implement IM contact sync and details 2026-07-17 15:17:29 +08:00
JzoNg b30c3578a7 feat(contacts): implement IM platform binding management UI 2026-07-17 15:17:29 +08:00
JzoNg ef5c3df88b feat(contacts): implement IM platform mock data boundary 2026-07-17 15:17:29 +08:00
JzoNg e35752ddbc fix(contacts): restrict IM platform entry to non-enterprise editions 2026-07-17 15:17:29 +08:00
JzoNg 5b5d62a2af feat(contacts): establish IM platform frontend baseline 2026-07-17 15:17:29 +08:00
JzoNg cdd111869b docs: human input node v2 2026-07-17 15:09:50 +08:00
JzoNg 4fd6c028af docs: contacts management ui 2026-07-17 15:09:50 +08:00
JzoNg abcc77204b docs: add IM platform binding 2026-07-17 15:09:50 +08:00
JzoNg dce8e971be Merge branch 'main' into tp 2026-07-17 15:09:02 +08:00
QuantumGhost 2249974daa feat: add API stub 2026-07-17 14:32:03 +08:00
QuantumGhost f49e3dd5b1 chore: clarify organization contact and platform contact 2026-07-17 14:32:03 +08:00
QuantumGhost 51c471a359 chore: remove outdated docs 2026-07-17 14:32:03 +08:00
QuantumGhost 1f05bdd543 chore: remove outdated docs 2026-07-17 14:32:03 +08:00
QuantumGhost 2250f83d0f chore: remove outdated docs 2026-07-17 14:32:03 +08:00
QuantumGhost 30fdbed7f2 chore: separate lark and feishu provider 2026-07-17 14:32:03 +08:00
QuantumGhost 3ff1c91d84 chore: remove outdated docs 2026-07-17 14:32:03 +08:00
QuantumGhost b9561cef58 chore: update specs 2026-07-17 14:32:02 +08:00
QuantumGhost ee1b9cb95f chore: update spec 2026-07-17 14:32:02 +08:00
QuantumGhost c1154686d8 chore: update openspec docs 2026-07-17 14:32:02 +08:00
JzoNg 3ead32a4e8 Merge branch 'main' into tp 2026-07-17 13:06:50 +08:00
JzoNg 6f1ecc47e8 Merge branch 'main' into tp 2026-07-17 11:24:24 +08:00
JzoNg 3d80e13f4f Merge branch 'main' into tp 2026-07-16 20:30:35 +08:00
JzoNg 75e2bcd460 Merge branch 'main' into tp 2026-07-16 17:47:25 +08:00
JzoNg 691eca212b Merge branch 'main' into tp 2026-07-16 11:25:16 +08:00
JzoNg e21a0883c1 Merge branch 'main' into tp 2026-07-16 08:13:27 +08:00
JzoNg ba1c13e37c Merge branch 'main' into tp 2026-07-14 14:16:26 +08:00
QuantumGhost 1e2f6f234e feat: add HITL IM DSL schema 2026-07-14 13:59:24 +08:00
QuantumGhost 6813e8d34f chore: introduce openspec 2026-07-14 10:36:52 +08:00
QuantumGhost e348d8accf Merge remote-tracking branch 'upstream/feat/hitl-im-dev' into feat/hitl-im-dev 2026-07-14 10:36:12 +08:00
QuantumGhost b71bbc2d3b Merge remote-tracking branch 'upstream/main' into feat/hitl-im-dev 2026-07-14 01:59:51 +08:00
JzoNg 69e18efab6 Merge branch 'main' into tp 2026-07-13 16:40:13 +08:00
QuantumGhost 196287fc8a docs: add HITL IM docs 2026-07-11 17:02:34 +08:00
648 changed files with 64684 additions and 32348 deletions
-1
View File
@@ -666,7 +666,6 @@ 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
-6
View File
@@ -266,12 +266,6 @@ 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,6 +25,8 @@ 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. "
@@ -0,0 +1,984 @@
"""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",
]
+4 -2
View File
@@ -76,6 +76,7 @@ from .app import (
workflow_app_log,
workflow_comment,
workflow_draft_variable,
workflow_human_input_v2,
workflow_node_output_inspector,
workflow_run,
workflow_statistic,
@@ -138,13 +139,13 @@ from .workspace import (
account,
agent_providers,
endpoint,
human_input,
load_balancing_config,
members,
model_providers,
models,
plugin,
rbac,
skills,
snippets,
tool_providers,
trigger_providers,
@@ -195,6 +196,7 @@ __all__ = [
"forgot_password",
"generator",
"hit_testing",
"human_input",
"human_input_form",
"init_validate",
"installed_app",
@@ -226,7 +228,6 @@ __all__ = [
"saved_message",
"setup",
"site",
"skills",
"snippet_workflow",
"snippet_workflow_draft_variable",
"snippets",
@@ -243,6 +244,7 @@ __all__ = [
"workflow_app_log",
"workflow_comment",
"workflow_draft_variable",
"workflow_human_input_v2",
"workflow_node_output_inspector",
"workflow_run",
"workflow_run_archive",
@@ -0,0 +1,70 @@
"""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()
+1 -1
View File
@@ -59,7 +59,7 @@ class TagBindingRemovePayload(BaseModel):
class TagListQueryParam(BaseModel):
type: TagType | Literal[""] = Field("", description="Tag type filter")
type: Literal["knowledge", "app", "snippet", ""] = Field("", description="Tag type filter")
keyword: str | None = Field(None, description="Search keyword")
@@ -0,0 +1,541 @@
"""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()
-834
View File
@@ -1,834 +0,0 @@
"""Console API for workspace-level Skill Management."""
from __future__ import annotations
import io
from flask import request, send_file
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from controllers.common.fields import BinaryFileResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import (
account_initialization_required,
edit_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from fields.base import ResponseModel
from libs import helper
from libs.helper import dump_response
from libs.login import login_required
from models.account import Account
from services.skill_management_service import (
SkillAssistMessagePayload,
SkillCreatePayload,
SkillDraftFileOperationPayload,
SkillDraftTreePayload,
SkillImportPayload,
SkillManagementService,
SkillManagementServiceError,
SkillMetadataPayload,
SkillPublishPayload,
SkillRestorePayload,
SkillVersionUpdatePayload,
)
_FILE_UPLOAD_PARAMS = {
"file": {
"description": "Skill draft file payload",
"in": "formData",
"type": "file",
"required": True,
},
}
class WorkspaceSkillsQuery(BaseModel):
keyword: str | None = Field(default=None, description="Search keyword matching skill name or description.")
page: int = Field(default=1, ge=1, le=99999, description="Page number.")
limit: int = Field(default=20, ge=1, le=100, description="Number of items per page.")
tag: list[str] = Field(
default_factory=list,
description="Skill tag filters. Repeat the parameter for multiple tags.",
)
class SkillDeletePayload(BaseModel):
model_config = ConfigDict(extra="forbid")
confirmation_name: str | None = Field(
default=None,
description="Required when deleting a referenced Skill. Must match the Skill name.",
)
class AgentSkillBindingsPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
skill_ids: list[str] = Field(default_factory=list, description="Ordered Skill IDs bound to the Agent.")
class SkillFileQuery(BaseModel):
path: str = Field(description="Skill file path relative to the Skill root.")
version_id: str | None = Field(default=None, description="Optional published version ID. Omit for current draft.")
class SkillResponse(ResponseModel):
id: str
name: str
display_name: str
icon: str
description: str
tags: list[str] = Field(default_factory=list)
name_manually_edited: bool = False
visibility: str
latest_published_version_id: str | None = None
reference_count: int = 0
created_by: str | None = None
created_by_name: str | None = None
updated_by: str | None = None
updated_by_name: str | None = None
created_at: int
updated_at: int
class SkillFileResponse(ResponseModel):
id: str | None = None
path: str
kind: str
storage: str | None = None
mime_type: str | None = None
content: str | None = None
tool_file_id: str | None = None
size: int | None = None
hash: str | None = None
class SkillFilePreviewResponse(ResponseModel):
path: str
mime_type: str
content: str
size: int
hash: str
class SkillFileUploadResponse(ResponseModel):
id: str
name: str
mime_type: str
size: int
hash: str
class SkillDetailResponse(SkillResponse):
files: list[SkillFileResponse] = Field(default_factory=list)
class SkillListResponse(ResponseModel):
data: list[SkillResponse] = Field(default_factory=list)
has_more: bool = False
limit: int = 20
page: int = 1
total: int = 0
class SkillTagResponse(ResponseModel):
tag: str
count: int
class SkillTagListResponse(ResponseModel):
data: list[SkillTagResponse] = Field(default_factory=list)
class SkillVersionResponse(ResponseModel):
id: str
skill_id: str
version_number: int
version_name: str
publish_note: str
hash_code: str
archive_size: int
published_by: str | None = None
published_by_name: str | None = None
is_latest: bool = False
created_at: int
class SkillVersionListResponse(ResponseModel):
data: list[SkillVersionResponse] = Field(default_factory=list)
class SkillVersionDetailResponse(SkillVersionResponse):
files: list[SkillFileResponse] = Field(default_factory=list)
class SkillVersionDeleteResponse(ResponseModel):
id: str
deleted: bool
latest_published_version_id: str | None = None
class SkillReferenceResponse(ResponseModel):
type: str
agent_id: str
agent_icon: str | None = None
agent_icon_background: str | None = None
agent_icon_type: str | None = None
app_id: str | None = None
name: str
display_name: str
workflow_id: str | None = None
workflow_name: str | None = None
workflow_icon: str | None = None
workflow_icon_background: str | None = None
workflow_icon_type: str | None = None
workflow_version: str | None = None
node_id: str | None = None
node_name: str | None = None
class SkillReferenceListResponse(ResponseModel):
data: list[SkillReferenceResponse] = Field(default_factory=list)
class SkillDeleteResponse(ResponseModel):
id: str
deleted: bool
class AgentSkillBindingItemResponse(ResponseModel):
id: str
priority: int
name: str
display_name: str
icon: str
description: str
tags: list[str] = Field(default_factory=list)
status: str
file_count: int
latest_published_version_id: str | None = None
latest_published_at: int | None = None
updated_at: int
class AgentSkillBindingsResponse(ResponseModel):
agent_id: str
skill_ids: list[str] = Field(default_factory=list)
data: list[AgentSkillBindingItemResponse] = Field(default_factory=list)
register_schema_models(
console_ns,
WorkspaceSkillsQuery,
SkillCreatePayload,
SkillAssistMessagePayload,
SkillMetadataPayload,
SkillDraftFileOperationPayload,
SkillDraftTreePayload,
SkillPublishPayload,
SkillRestorePayload,
SkillVersionUpdatePayload,
SkillDeletePayload,
SkillFileQuery,
AgentSkillBindingsPayload,
)
register_response_schema_models(
console_ns,
SkillResponse,
SkillFileResponse,
SkillFilePreviewResponse,
SkillFileUploadResponse,
SkillDetailResponse,
SkillListResponse,
SkillTagResponse,
SkillTagListResponse,
SkillVersionResponse,
SkillVersionListResponse,
SkillVersionDetailResponse,
SkillVersionDeleteResponse,
SkillReferenceResponse,
SkillReferenceListResponse,
SkillDeleteResponse,
AgentSkillBindingItemResponse,
AgentSkillBindingsResponse,
BinaryFileResponse,
)
def _error_response(exc: SkillManagementServiceError) -> tuple[dict[str, object], int]:
body: dict[str, object] = {"code": exc.code, "message": exc.message}
if exc.details:
body["details"] = exc.details
return body, exc.status_code
@console_ns.route("/workspaces/current/skills")
class WorkspaceSkillsApi(Resource):
@console_ns.doc(params=query_params_from_model(WorkspaceSkillsQuery))
@console_ns.response(200, "Workspace skills", console_ns.models[SkillListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str):
query_input: dict[str, object] = {
"keyword": request.args.get("keyword"),
"tag": request.args.getlist("tag"),
}
if "limit" in request.args:
query_input["limit"] = request.args.get("limit")
if "page" in request.args:
query_input["page"] = request.args.get("page")
query = WorkspaceSkillsQuery.model_validate(query_input)
result = SkillManagementService().list_skills(
tenant_id=current_tenant_id,
keyword=query.keyword,
page=query.page,
limit=query.limit,
tags=[tag for tag in query.tag if tag],
)
return dump_response(SkillListResponse, result)
@console_ns.expect(console_ns.models[SkillCreatePayload.__name__])
@console_ns.response(201, "Skill created", console_ns.models[SkillDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account):
try:
payload = SkillCreatePayload.model_validate(console_ns.payload or {})
result = SkillManagementService().create_skill(
tenant_id=current_tenant_id,
user_id=current_user.id,
payload=payload,
)
return dump_response(SkillDetailResponse, result), 201
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/files/upload")
class WorkspaceSkillFileUploadApi(Resource):
@console_ns.doc(consumes=["multipart/form-data"], params=_FILE_UPLOAD_PARAMS)
@console_ns.response(201, "Skill draft file uploaded", console_ns.models[SkillFileUploadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account):
if "file" not in request.files:
return {"code": "no_file_uploaded", "message": "no file uploaded"}, 400
file = request.files["file"]
if not file.filename:
return {"code": "filename_missing", "message": "filename is required"}, 400
result = SkillManagementService().upload_file(
tenant_id=current_tenant_id,
user_id=current_user.id,
filename=file.filename,
content=file.stream.read(),
mime_type=file.mimetype,
)
return dump_response(SkillFileUploadResponse, result), 201
@console_ns.route("/workspaces/current/skills/tags")
class WorkspaceSkillTagsApi(Resource):
@console_ns.response(200, "Workspace Skill tags", console_ns.models[SkillTagListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str):
result = SkillManagementService().list_tags(tenant_id=current_tenant_id)
return dump_response(SkillTagListResponse, result)
@console_ns.route("/workspaces/current/skills/import")
class WorkspaceSkillImportApi(Resource):
@console_ns.doc(description="Import a Skill zip package from multipart form field `file`.")
@console_ns.response(201, "Skill imported", console_ns.models[SkillDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account):
upload = request.files.get("file")
if upload is None:
return {"code": "invalid_request", "message": "file is required"}, 400
try:
payload = SkillImportPayload(content=upload.read(), filename=upload.filename or "skill.zip")
result = SkillManagementService().import_skill(
tenant_id=current_tenant_id,
user_id=current_user.id,
payload=payload,
)
return dump_response(SkillDetailResponse, result), 201
except (ValidationError, ValueError) as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>")
class WorkspaceSkillApi(Resource):
@console_ns.response(200, "Skill detail", console_ns.models[SkillDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, skill_id: str):
try:
result = SkillManagementService().get_skill(tenant_id=current_tenant_id, skill_id=skill_id)
return dump_response(SkillDetailResponse, result)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.expect(console_ns.models[SkillMetadataPayload.__name__])
@console_ns.response(200, "Skill updated", console_ns.models[SkillResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def patch(self, current_tenant_id: str, current_user: Account, skill_id: str):
try:
payload = SkillMetadataPayload.model_validate(console_ns.payload or {})
result = SkillManagementService().update_metadata(
tenant_id=current_tenant_id,
user_id=current_user.id,
skill_id=skill_id,
payload=payload,
)
return dump_response(SkillResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.expect(console_ns.models[SkillDeletePayload.__name__])
@console_ns.response(200, "Skill deleted", console_ns.models[SkillDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_tenant_id
def delete(self, current_tenant_id: str, skill_id: str):
try:
payload = SkillDeletePayload.model_validate(console_ns.payload or {})
result = SkillManagementService().delete_skill(
tenant_id=current_tenant_id,
skill_id=skill_id,
confirmation_name=payload.confirmation_name,
)
return dump_response(SkillDeleteResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/duplicate")
class WorkspaceSkillDuplicateApi(Resource):
@console_ns.response(201, "Skill duplicated", console_ns.models[SkillDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, skill_id: str):
try:
result = SkillManagementService().duplicate_skill(
tenant_id=current_tenant_id,
user_id=current_user.id,
skill_id=skill_id,
)
return dump_response(SkillDetailResponse, result), 201
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/export")
class WorkspaceSkillExportApi(Resource):
@console_ns.response(200, "Published Skill zip archive")
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, skill_id: str):
try:
result = SkillManagementService().pull_published_archive(tenant_id=current_tenant_id, skill_id=skill_id)
return send_file(
io.BytesIO(result.payload),
mimetype=result.mime_type,
as_attachment=True,
download_name=result.filename,
)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/assist/messages")
class WorkspaceSkillAssistMessageApi(Resource):
"""Stream read-only Skill Authoring suggestions from the default workspace model."""
@console_ns.expect(console_ns.models[SkillAssistMessagePayload.__name__])
@console_ns.response(200, "Skill Authoring assistant event stream")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, skill_id: str):
try:
payload = SkillAssistMessagePayload.model_validate(console_ns.payload or {})
response = SkillManagementService().create_assistant_action_stream(
tenant_id=current_tenant_id,
skill_id=skill_id,
user_id=current_user.id,
message=payload.message,
attachments=payload.attachments,
model_payload=payload.model,
target_path=payload.target_path,
)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
return helper.compact_generate_response(response)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files")
class WorkspaceSkillFilesApi(Resource):
@console_ns.expect(console_ns.models[SkillDraftFileOperationPayload.__name__])
@console_ns.response(200, "Draft file operation applied", console_ns.models[SkillDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def patch(self, current_tenant_id: str, current_user: Account, skill_id: str):
try:
payload = SkillDraftFileOperationPayload.model_validate(console_ns.payload or {})
result = SkillManagementService().apply_draft_file_operation(
tenant_id=current_tenant_id,
user_id=current_user.id,
skill_id=skill_id,
payload=payload,
)
return dump_response(SkillDetailResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.expect(console_ns.models[SkillDraftTreePayload.__name__])
@console_ns.response(200, "Draft files replaced", console_ns.models[SkillDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def put(self, current_tenant_id: str, current_user: Account, skill_id: str):
try:
payload = SkillDraftTreePayload.model_validate(console_ns.payload or {})
result = SkillManagementService().replace_draft_tree(
tenant_id=current_tenant_id,
user_id=current_user.id,
skill_id=skill_id,
payload=payload,
)
return dump_response(SkillDetailResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files/preview")
class WorkspaceSkillFilePreviewApi(Resource):
@console_ns.doc(params=query_params_from_model(SkillFileQuery))
@console_ns.response(200, "Skill file text preview", console_ns.models[SkillFilePreviewResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, skill_id: str):
try:
query = SkillFileQuery.model_validate(
{
"path": request.args.get("path"),
"version_id": request.args.get("version_id"),
}
)
result = SkillManagementService().preview_file(
tenant_id=current_tenant_id,
skill_id=skill_id,
path=query.path,
version_id=query.version_id,
)
return dump_response(SkillFilePreviewResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files/content")
class WorkspaceSkillFileContentApi(Resource):
@console_ns.doc(params={**query_params_from_model(SkillFileQuery), "download": "Return as an attachment when 1."})
@console_ns.response(200, "Skill file content", console_ns.models[BinaryFileResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, skill_id: str):
try:
query = SkillFileQuery.model_validate(
{
"path": request.args.get("path"),
"version_id": request.args.get("version_id"),
}
)
result = SkillManagementService().pull_file(
tenant_id=current_tenant_id,
skill_id=skill_id,
path=query.path,
version_id=query.version_id,
)
return send_file(
io.BytesIO(result.payload),
mimetype=result.mime_type,
as_attachment=request.args.get("download") == "1",
download_name=result.filename,
)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/publish")
class WorkspaceSkillPublishApi(Resource):
@console_ns.expect(console_ns.models[SkillPublishPayload.__name__])
@console_ns.response(200, "Skill published", console_ns.models[SkillVersionResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, skill_id: str):
try:
payload = SkillPublishPayload.model_validate(console_ns.payload or {})
result = SkillManagementService().publish_skill(
tenant_id=current_tenant_id,
user_id=current_user.id,
skill_id=skill_id,
payload=payload,
)
return dump_response(SkillVersionResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/restore")
class WorkspaceSkillRestoreApi(Resource):
@console_ns.expect(console_ns.models[SkillRestorePayload.__name__])
@console_ns.response(200, "Skill version restored", console_ns.models[SkillVersionResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, skill_id: str):
try:
payload = SkillRestorePayload.model_validate(console_ns.payload or {})
result = SkillManagementService().restore_version(
tenant_id=current_tenant_id,
user_id=current_user.id,
skill_id=skill_id,
payload=payload,
)
return dump_response(SkillVersionResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/references")
class WorkspaceSkillReferencesApi(Resource):
@console_ns.response(200, "Skill references", console_ns.models[SkillReferenceListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, skill_id: str):
try:
result = SkillManagementService().list_skill_references(tenant_id=current_tenant_id, skill_id=skill_id)
return dump_response(SkillReferenceListResponse, result)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/versions")
class WorkspaceSkillVersionsApi(Resource):
@console_ns.response(200, "Skill versions", console_ns.models[SkillVersionListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, skill_id: str):
try:
result = SkillManagementService().list_versions(tenant_id=current_tenant_id, skill_id=skill_id)
return dump_response(SkillVersionListResponse, result)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/versions/<string:version_id>")
class WorkspaceSkillVersionApi(Resource):
@console_ns.response(200, "Skill version detail", console_ns.models[SkillVersionDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, skill_id: str, version_id: str):
try:
result = SkillManagementService().get_version(
tenant_id=current_tenant_id,
skill_id=skill_id,
version_id=version_id,
)
return dump_response(SkillVersionDetailResponse, result)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.expect(console_ns.models[SkillVersionUpdatePayload.__name__])
@console_ns.response(200, "Skill version updated", console_ns.models[SkillVersionResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_tenant_id
def patch(self, current_tenant_id: str, skill_id: str, version_id: str):
try:
payload = SkillVersionUpdatePayload.model_validate(console_ns.payload or {})
result = SkillManagementService().update_version(
tenant_id=current_tenant_id,
skill_id=skill_id,
version_id=version_id,
payload=payload,
)
return dump_response(SkillVersionResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.response(200, "Skill version deleted", console_ns.models[SkillVersionDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def delete(self, current_tenant_id: str, current_user: Account, skill_id: str, version_id: str):
try:
result = SkillManagementService().delete_version(
tenant_id=current_tenant_id,
user_id=current_user.id,
skill_id=skill_id,
version_id=version_id,
)
return dump_response(SkillVersionDeleteResponse, result)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/agents/<string:agent_id>/skills")
class WorkspaceAgentSkillBindingsApi(Resource):
@console_ns.response(200, "Agent Skill bindings", console_ns.models[AgentSkillBindingsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, agent_id: str):
result = SkillManagementService().list_agent_bindings(tenant_id=current_tenant_id, agent_id=agent_id)
return dump_response(AgentSkillBindingsResponse, result)
@console_ns.expect(console_ns.models[AgentSkillBindingsPayload.__name__])
@console_ns.response(200, "Agent Skill bindings replaced", console_ns.models[AgentSkillBindingsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def put(self, current_tenant_id: str, current_user: Account, agent_id: str):
try:
payload = AgentSkillBindingsPayload.model_validate(console_ns.payload or {})
result = SkillManagementService().replace_agent_bindings(
tenant_id=current_tenant_id,
user_id=current_user.id,
agent_id=agent_id,
skill_ids=payload.skill_ids,
)
return dump_response(AgentSkillBindingsResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
__all__ = [
"WorkspaceAgentSkillBindingsApi",
"WorkspaceSkillApi",
"WorkspaceSkillDuplicateApi",
"WorkspaceSkillExportApi",
"WorkspaceSkillFilesApi",
"WorkspaceSkillImportApi",
"WorkspaceSkillPublishApi",
"WorkspaceSkillReferencesApi",
"WorkspaceSkillRestoreApi",
"WorkspaceSkillTagsApi",
"WorkspaceSkillVersionApi",
"WorkspaceSkillVersionsApi",
"WorkspaceSkillsApi",
]
+2 -2
View File
@@ -23,7 +23,7 @@ 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 .plugin import skills as _skills
from .workspace import plugin_model_providers as _plugin_model_providers
from .workspace import workspace as _workspace
api.add_namespace(inner_api_ns)
@@ -36,8 +36,8 @@ __all__ = [
"_knowledge_retrieval",
"_mail",
"_plugin",
"_plugin_model_providers",
"_runtime_credentials",
"_skills",
"_workspace",
"api",
"bp",
@@ -1,54 +0,0 @@
"""Inner API for published workspace Skills.
These endpoints are called by trusted runtime services. They expose only
published Skill artifacts, never draft files or editable metadata.
"""
from __future__ import annotations
import io
from flask import request, send_file
from flask_restx import Resource
from pydantic import BaseModel, ValidationError
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import plugin_inner_api_only
from services.skill_management_service import SkillManagementService, SkillManagementServiceError
class _SkillTargetQuery(BaseModel):
tenant_id: str
def _target_query_from_request() -> _SkillTargetQuery:
return _SkillTargetQuery.model_validate({"tenant_id": request.args.get("tenant_id")})
def _error_response(exc: SkillManagementServiceError) -> tuple[dict[str, str], int]:
return {"code": exc.code, "message": exc.message}, exc.status_code
@inner_api_ns.route("/skills/<string:skill_id>/pull")
class PublishedSkillPullApi(Resource):
@setup_required
@plugin_inner_api_only
@inner_api_ns.doc("published_skill_pull")
def get(self, skill_id: str):
try:
query = _target_query_from_request()
result = SkillManagementService().pull_published_archive(tenant_id=query.tenant_id, skill_id=skill_id)
return send_file(
io.BytesIO(result.payload),
mimetype=result.mime_type,
as_attachment=True,
download_name=result.filename,
)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
__all__ = ["PublishedSkillPullApi"]
@@ -0,0 +1,39 @@
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,14 +8,18 @@ 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
from flask import Response, abort, request
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
@@ -43,8 +47,19 @@ class HumanInputFormSubmitResponse(ResponseModel):
model_config = ConfigDict(extra="forbid")
register_schema_models(service_api_ns, HumanInputFormSubmitPayload)
register_response_schema_models(service_api_ns, HumanInputFormDefinitionResponse, HumanInputFormSubmitResponse)
register_schema_models(
service_api_ns,
HumanInputFormSubmitPayload,
HumanInputV2ServiceFormSubmitRequest,
ServiceFormQuery,
)
register_response_schema_models(
service_api_ns,
HumanInputFormDefinitionResponse,
HumanInputFormSubmitResponse,
HumanInputV2FormDefinitionResponse,
HumanInputV2FormSubmitResponse,
)
def _jsonify_form_definition(form: Form, *, inputs: Sequence[FormInputConfig] = ()) -> Response:
@@ -188,3 +203,33 @@ 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,6 +25,7 @@ from . import (
forgot_password,
human_input_file_upload,
human_input_form,
human_input_form_access_request,
login,
message,
passport,
@@ -49,6 +50,7 @@ __all__ = [
"forgot_password",
"human_input_file_upload",
"human_input_form",
"human_input_form_access_request",
"login",
"message",
"passport",
@@ -0,0 +1,67 @@
"""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()
@@ -43,7 +43,6 @@ from core.workflow.nodes.agent_v2.runtime_request_builder import (
build_config_layer_config,
build_knowledge_layer_config,
build_shell_layer_config,
load_runtime_agent_skill_configs,
)
from models.agent_config_entities import AgentSoulConfig, AgentSoulToolsConfig
from models.provider_ids import ModelProviderID
@@ -126,22 +125,14 @@ class AgentAppRuntimeRequestBuilder:
"cli_tool_count": len(agent_soul.tools.cli_tools),
}
runtime_config_skills = load_runtime_agent_skill_configs(
tenant_id=context.dify_context.tenant_id,
agent_id=context.agent_id,
)
config_layer_config, config_warnings = build_config_layer_config(
agent_soul,
agent_id=context.agent_id,
config_version_id=context.agent_config_snapshot_id,
config_version_kind=context.agent_config_version_kind,
runtime_config_skills=runtime_config_skills,
)
append_runtime_warnings(metadata, config_warnings)
soul_prompt_resolver = build_config_aware_soul_mention_resolver(
agent_soul,
runtime_config_skills=runtime_config_skills,
)
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
knowledge_config = build_knowledge_layer_config(agent_soul)
request = self._request_builder.build_for_agent_app(
+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_email_task
from tasks.mail_human_input_delivery_task import dispatch_human_input_form_delivery_task
logger = logging.getLogger(__name__)
@@ -715,12 +715,12 @@ class WorkflowBasedAppRunner:
if not reason.form_id:
continue
try:
dispatch_human_input_email_task.apply_async(
dispatch_human_input_form_delivery_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 email task for form %s", reason.form_id)
logger.exception("Failed to enqueue human input form delivery task for form %s", reason.form_id)
def _publish_event(self, event: AppQueueEvent):
self._queue_manager.publish(event, PublishFrom.APPLICATION_MANAGER)
View File
@@ -0,0 +1,236 @@
"""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",
]
@@ -0,0 +1,287 @@
"""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
@@ -0,0 +1,261 @@
"""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))
@@ -0,0 +1,100 @@
"""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
@@ -0,0 +1,134 @@
"""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
@@ -0,0 +1,460 @@
"""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
@@ -0,0 +1,81 @@
"""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."""
...
@@ -0,0 +1,775 @@
"""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 ""
@@ -0,0 +1,145 @@
"""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)
@@ -0,0 +1,405 @@
"""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",
]
@@ -0,0 +1,117 @@
"""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",
]
@@ -0,0 +1,102 @@
"""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",
]
@@ -0,0 +1,37 @@
"""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",
]
@@ -0,0 +1,229 @@
"""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
@@ -0,0 +1,48 @@
"""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))
@@ -0,0 +1,101 @@
"""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)
@@ -0,0 +1,47 @@
"""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
@@ -0,0 +1,197 @@
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",
]
@@ -0,0 +1,82 @@
"""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",
]
@@ -0,0 +1,178 @@
"""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,
)
@@ -0,0 +1,236 @@
"""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,
)
@@ -0,0 +1,123 @@
"""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",
]
@@ -0,0 +1,145 @@
"""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,
)
@@ -0,0 +1,18 @@
"""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, ...]
@@ -0,0 +1,276 @@
"""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)
@@ -0,0 +1,63 @@
"""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
@@ -0,0 +1,173 @@
"""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")
+23 -38
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, PluginInstallationPermissionModel, PluginInstallationScope
from services.feature_service import FeatureService, PluginInstallationScope
logger = logging.getLogger(__name__)
_provider_entities_adapter: TypeAdapter[list[ProviderEntity]] = TypeAdapter(list[ProviderEntity])
@@ -434,18 +434,14 @@ 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, ...]:
providers = cls._fetch_plugin_model_providers_uncached(tenant_id, client)
model_client = client or PluginModelClient()
providers = tuple(
cls._to_provider_entity(provider) for provider in model_client.fetch_model_providers(tenant_id)
)
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)
@@ -475,9 +471,6 @@ 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:
@@ -604,30 +597,22 @@ class PluginService:
return result
@staticmethod
def _check_marketplace_only_permission() -> None:
def _check_marketplace_only_permission():
"""
Check if the marketplace only permission is enabled
"""
permission = PluginService._get_plugin_installation_permission()
if permission.restrict_to_marketplace_only:
features = FeatureService.get_system_features()
if features.plugin_installation_permission.restrict_to_marketplace_only:
raise PluginInstallationForbiddenError("Plugin installation is restricted to marketplace only")
@staticmethod
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:
def _check_plugin_installation_scope(plugin_verification: PluginVerification | None):
"""
Check the plugin installation scope
"""
permission = PluginService._get_plugin_installation_permission()
features = FeatureService.get_system_features()
match permission.plugin_installation_scope:
match features.plugin_installation_permission.plugin_installation_scope:
case PluginInstallationScope.OFFICIAL_ONLY:
if (
plugin_verification is None
@@ -642,10 +627,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:
@@ -915,7 +900,7 @@ class PluginService:
# check if plugin pkg is already downloaded
manager = PluginInstaller()
permission = PluginService._get_plugin_installation_permission()
features = FeatureService.get_system_features()
try:
manager.fetch_plugin_manifest(tenant_id, new_plugin_unique_identifier)
@@ -927,7 +912,7 @@ class PluginService:
response = manager.upload_pkg(
tenant_id,
pkg,
verify_signature=permission.restrict_to_marketplace_only,
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
)
# check if the plugin is available to install
@@ -982,11 +967,11 @@ class PluginService:
"""
PluginService._check_marketplace_only_permission()
manager = PluginInstaller()
permission = PluginService._get_plugin_installation_permission()
features = FeatureService.get_system_features()
response = manager.upload_pkg(
tenant_id,
pkg,
verify_signature=permission.restrict_to_marketplace_only,
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
)
PluginService._check_plugin_installation_scope(response.verification)
@@ -1004,13 +989,13 @@ class PluginService:
pkg = download_with_size_limit(
f"https://github.com/{repo}/releases/download/{version}/{package}", dify_config.PLUGIN_MAX_PACKAGE_SIZE
)
permission = PluginService._get_plugin_installation_permission()
features = FeatureService.get_system_features()
manager = PluginInstaller()
response = manager.upload_pkg(
tenant_id,
pkg,
verify_signature=permission.restrict_to_marketplace_only,
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
)
PluginService._check_plugin_installation_scope(response.verification)
@@ -1084,7 +1069,7 @@ class PluginService:
if not dify_config.MARKETPLACE_ENABLED:
raise ValueError("marketplace is not enabled")
permission = PluginService._get_plugin_installation_permission()
features = FeatureService.get_system_features()
manager = PluginInstaller()
try:
@@ -1094,7 +1079,7 @@ class PluginService:
response = manager.upload_pkg(
tenant_id,
pkg,
verify_signature=permission.restrict_to_marketplace_only,
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
)
# check if the plugin is available to install
PluginService._check_plugin_installation_scope(response.verification)
@@ -1116,7 +1101,7 @@ class PluginService:
# collect actual plugin_unique_identifiers
actual_plugin_unique_identifiers = []
metas = []
permission = PluginService._get_plugin_installation_permission()
features = FeatureService.get_system_features()
# check if already downloaded
for plugin_unique_identifier in plugin_unique_identifiers:
@@ -1134,7 +1119,7 @@ class PluginService:
response = manager.upload_pkg(
tenant_id,
pkg,
verify_signature=permission.restrict_to_marketplace_only,
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
)
# check if the plugin is available to install
PluginService._check_plugin_installation_scope(response.verification)
@@ -14,6 +14,10 @@ from core.workflow.human_input_adapter import (
EmailDeliveryMethod,
EmailRecipients,
ExternalRecipient,
InstantMessageChannelRecipient,
InstantMessageDeliveryConfig,
InstantMessageDeliveryMethod,
InstantMessageUserRecipient,
InteractiveSurfaceDeliveryMethod,
is_human_input_webapp_enabled,
)
@@ -32,6 +36,7 @@ from models.human_input import (
HumanInputDelivery,
HumanInputForm,
HumanInputFormRecipient,
InstantMessageRecipientPayload,
RecipientType,
StandaloneWebAppRecipientPayload,
)
@@ -323,9 +328,48 @@ 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,
-2
View File
@@ -107,8 +107,6 @@ 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."""
+80 -4
View File
@@ -5,6 +5,8 @@ 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
@@ -26,6 +28,7 @@ from graphon.variables.consts import SELECTORS_LENGTH
class DeliveryMethodType(enum.StrEnum):
WEBAPP = enum.auto()
EMAIL = enum.auto()
IM = enum.auto()
class EmailRecipientType(enum.StrEnum):
@@ -34,6 +37,17 @@ 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
@@ -56,6 +70,25 @@ 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")
@@ -66,6 +99,12 @@ 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]] = [
@@ -141,6 +180,14 @@ 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)
@@ -170,10 +217,31 @@ 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, Field(discriminator="type")]
DeliveryChannelConfig = Annotated[
InteractiveSurfaceDeliveryMethod | EmailDeliveryMethod | InstantMessageDeliveryMethod,
Field(discriminator="type"),
]
_DELIVERY_METHODS_ADAPTER = TypeAdapter(list[DeliveryChannelConfig])
@@ -204,9 +272,10 @@ 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:
recipients_mapping = _copy_mapping(config_mapping.get("recipients"))
if recipients_mapping is not None:
config_mapping["recipients"] = _normalize_email_recipients(recipients_mapping)
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)
method_mapping["config"] = config_mapping
normalized_methods.append(method_mapping)
@@ -377,6 +446,13 @@ __all__ = [
"EmailRecipientType",
"EmailRecipients",
"ExternalRecipient",
"InstantMessageChannelRecipient",
"InstantMessageDeliveryConfig",
"InstantMessageDeliveryMethod",
"InstantMessageProvider",
"InstantMessageRecipientType",
"InstantMessageRecipients",
"InstantMessageUserRecipient",
"MemberRecipient",
"WebAppDeliveryMethod",
"_WebAppDeliveryConfig",
@@ -38,7 +38,6 @@ from dify_agent.layers.shell import (
)
from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload
from pydantic import BaseModel, ValidationError
from sqlalchemy.exc import OperationalError
from clients.agent_backend import (
AgentBackendModelConfig,
@@ -207,22 +206,14 @@ class WorkflowAgentRuntimeRequestBuilder:
"cli_tool_count": len(agent_soul.tools.cli_tools),
}
runtime_config_skills = load_runtime_agent_skill_configs(
tenant_id=context.dify_context.tenant_id,
agent_id=context.agent.id,
)
config_layer_config, config_warnings = build_config_layer_config(
agent_soul,
agent_id=context.agent.id,
config_version_id=context.snapshot.id,
config_version_kind="snapshot",
runtime_config_skills=runtime_config_skills,
)
append_runtime_warnings(metadata, config_warnings)
soul_prompt_resolver = build_config_aware_soul_mention_resolver(
agent_soul,
runtime_config_skills=runtime_config_skills,
)
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
knowledge_config = build_knowledge_layer_config(agent_soul)
@@ -892,16 +883,11 @@ def append_runtime_warnings(metadata: dict[str, Any], warnings: list[dict[str, s
existing.extend(warnings)
def build_config_aware_soul_mention_resolver(
agent_soul: AgentSoulConfig,
*,
runtime_config_skills: Sequence[DifyConfigSkillConfig] = (),
):
def build_config_aware_soul_mention_resolver(agent_soul: AgentSoulConfig):
"""Resolve config skill/file mentions and delegate the rest to Agent Soul."""
base_resolver = build_soul_mention_resolver(agent_soul)
skill_names = {item.name for item in agent_soul.config_skills if not item.is_missing}
skill_names.update(item.name for item in runtime_config_skills)
file_names = {item.name for item in agent_soul.config_files if not item.is_missing}
def _resolve(mention: object) -> str | None:
@@ -919,34 +905,12 @@ def build_config_aware_soul_mention_resolver(
return _resolve
def load_runtime_agent_skill_configs(*, tenant_id: str, agent_id: str) -> list[DifyConfigSkillConfig]:
"""Return workspace-bound Skills as prompt-safe runtime config skills."""
from services.skill_management_service import SkillManagementService
try:
runtime_skills = SkillManagementService().list_runtime_agent_skills(tenant_id=tenant_id, agent_id=agent_id)
except OperationalError as exc:
if "no such table: agent_skill_bindings" not in str(exc.orig):
raise
runtime_skills = []
return [
DifyConfigSkillConfig(
name=str(item["name"]),
description=str(item.get("description") or ""),
size=cast(int | None, item.get("size")),
mime_type=cast(str | None, item.get("mime_type")),
)
for item in runtime_skills
]
def build_config_layer_config(
agent_soul: AgentSoulConfig,
*,
agent_id: str | None = None,
config_version_id: str | None = None,
config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot",
runtime_config_skills: Sequence[DifyConfigSkillConfig] = (),
) -> tuple[DifyConfigLayerConfig, list[dict[str, str]]]:
"""Build the always-present Agent config layer from Agent Soul state.
@@ -963,23 +927,8 @@ def build_config_layer_config(
)
)
available_skills = [skill for skill in agent_soul.config_skills if not skill.is_missing]
skill_configs = [
DifyConfigSkillConfig(
name=skill.name,
description=skill.description,
size=skill.size,
mime_type=skill.mime_type,
)
for skill in available_skills
]
seen_skill_names = {skill.name for skill in skill_configs}
for skill in runtime_config_skills:
if skill.name in seen_skill_names:
continue
seen_skill_names.add(skill.name)
skill_configs.append(skill)
available_files = [file_ref for file_ref in agent_soul.config_files if not file_ref.is_missing]
skill_names = {skill.name for skill in skill_configs}
skill_names = {skill.name for skill in available_skills}
file_names = {file_ref.name for file_ref in available_files}
warnings: list[dict[str, str]] = [
{
@@ -1016,7 +965,15 @@ def build_config_layer_config(
kind=config_version_kind,
writable=config_version_kind == "build_draft",
),
skills=skill_configs,
skills=[
DifyConfigSkillConfig(
name=skill.name,
description=skill.description,
size=skill.size,
mime_type=skill.mime_type,
)
for skill in available_skills
],
files=[
DifyConfigFileConfig(
name=file_ref.name,
@@ -13,6 +13,7 @@ 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
@@ -372,3 +373,10 @@ 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])
@@ -0,0 +1,91 @@
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
+12
View File
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,5 @@
from typing import Annotated
from pydantic import Field
Timestamp = Annotated[int, Field(..., description="Unix timestamp in milliseconds")]
+1 -5
View File
@@ -289,11 +289,7 @@ UUIDStr = Annotated[str, AfterValidator(_strict_uuid)]
def alphanumeric(value: str):
# check if the value is alphanumeric and underlined
# 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):
if re.match(r"^[a-zA-Z0-9_]+$", value):
return value
raise ValueError(f"{value} is not a valid alphanumeric value")
@@ -1,114 +0,0 @@
"""add workspace skill management
Revision ID: a4f8d2c9e1b0
Revises: 6f5a9c2d8e1b
Create Date: 2026-07-09 12:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import mysql
from models.types import StringUUID
# revision identifiers, used by Alembic.
revision = "a4f8d2c9e1b0"
down_revision = "6f5a9c2d8e1b"
branch_labels = None
depends_on = None
def _uuid_column(name: str, *, nullable: bool = False) -> sa.Column:
return sa.Column(name, StringUUID(), nullable=nullable)
def _long_text() -> sa.types.TypeEngine:
return sa.Text().with_variant(mysql.LONGTEXT(), "mysql")
def upgrade() -> None:
op.create_table(
"skills",
_uuid_column("id"),
_uuid_column("tenant_id"),
sa.Column("name", sa.String(length=64), nullable=False),
sa.Column("display_name", sa.String(length=128), nullable=False),
sa.Column("icon", sa.String(length=16), nullable=False, server_default="📄"),
sa.Column("description", sa.String(length=1024), nullable=False, server_default=""),
sa.Column("name_manually_edited", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("visibility", sa.String(length=32), nullable=False, server_default="workspace"),
_uuid_column("latest_published_version_id", nullable=True),
_uuid_column("created_by", nullable=True),
_uuid_column("updated_by", nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.PrimaryKeyConstraint("id", name="skill_pkey"),
sa.UniqueConstraint("tenant_id", "name", name="skill_tenant_name_unique"),
)
op.create_index("skills_tenant_updated_at_idx", "skills", ["tenant_id", "updated_at"])
op.create_table(
"skill_draft_files",
_uuid_column("id"),
_uuid_column("skill_id"),
sa.Column("path", sa.String(length=512), nullable=False),
sa.Column("kind", sa.String(length=32), nullable=False),
sa.Column("storage", sa.String(length=32), nullable=True),
sa.Column("mime_type", sa.String(length=255), nullable=True),
sa.Column("content_text", _long_text(), nullable=True),
_uuid_column("tool_file_id", nullable=True),
sa.Column("size", sa.BigInteger(), nullable=True),
sa.Column("hash", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.PrimaryKeyConstraint("id", name="skill_draft_file_pkey"),
sa.UniqueConstraint("skill_id", "path", name="skill_draft_file_skill_path_unique"),
)
op.create_index("skill_draft_files_skill_path_idx", "skill_draft_files", ["skill_id", "path"])
op.create_table(
"skill_versions",
_uuid_column("id"),
_uuid_column("skill_id"),
sa.Column("version_number", sa.Integer(), nullable=False),
sa.Column("version_name", sa.String(length=128), nullable=False, server_default=""),
sa.Column("publish_note", sa.String(length=1024), nullable=False, server_default=""),
sa.Column("manifest", _long_text(), nullable=False),
_uuid_column("archive_tool_file_id"),
sa.Column("hash_code", sa.String(length=255), nullable=False),
sa.Column("archive_size", sa.BigInteger(), nullable=False),
_uuid_column("published_by", nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.PrimaryKeyConstraint("id", name="skill_version_pkey"),
sa.UniqueConstraint("skill_id", "version_number", name="skill_version_skill_number_unique"),
)
op.create_index("skill_versions_skill_created_at_idx", "skill_versions", ["skill_id", "created_at"])
op.create_table(
"agent_skill_bindings",
_uuid_column("id"),
_uuid_column("tenant_id"),
_uuid_column("agent_id"),
_uuid_column("skill_id"),
sa.Column("priority", sa.Integer(), nullable=False),
_uuid_column("created_by", nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.PrimaryKeyConstraint("id", name="agent_skill_binding_pkey"),
sa.UniqueConstraint("tenant_id", "agent_id", "skill_id", name="agent_skill_binding_unique"),
sa.UniqueConstraint("tenant_id", "agent_id", "priority", name="agent_skill_binding_priority_unique"),
)
op.create_index("agent_skill_bindings_skill_idx", "agent_skill_bindings", ["tenant_id", "skill_id"])
def downgrade() -> None:
op.drop_index("agent_skill_bindings_skill_idx", table_name="agent_skill_bindings")
op.drop_table("agent_skill_bindings")
op.drop_index("skill_versions_skill_created_at_idx", table_name="skill_versions")
op.drop_table("skill_versions")
op.drop_index("skill_draft_files_skill_path_idx", table_name="skill_draft_files")
op.drop_table("skill_draft_files")
op.drop_index("skills_tenant_updated_at_idx", table_name="skills")
op.drop_table("skills")
@@ -18,109 +18,31 @@ branch_labels = None
depends_on = None
def _is_pg(conn) -> bool:
return conn.dialect.name == "postgresql"
def _uuid_column(name: str, *, nullable: bool = False, primary_key: bool = False) -> sa.Column:
kwargs = {"nullable": nullable, "primary_key": primary_key}
if primary_key and _is_pg(op.get_bind()):
kwargs["server_default"] = sa.text("uuidv7()")
return sa.Column(name, models.types.StringUUID(), **kwargs)
def _has_table(table_name: str) -> bool:
return sa.inspect(op.get_bind()).has_table(table_name)
def _has_column(table_name: str, column_name: str) -> bool:
return any(
column["name"] == column_name for column in sa.inspect(op.get_bind()).get_columns(table_name)
)
def _has_unique_constraint(table_name: str, constraint_name: str) -> bool:
return any(
constraint["name"] == constraint_name
for constraint in sa.inspect(op.get_bind()).get_unique_constraints(table_name)
)
def upgrade():
if not _has_table("agent_debug_conversations"):
op.create_table(
"agent_debug_conversations",
_uuid_column("id", primary_key=True),
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
sa.Column("agent_id", models.types.StringUUID(), nullable=False),
sa.Column("app_id", models.types.StringUUID(), nullable=False),
sa.Column("account_id", models.types.StringUUID(), nullable=False),
sa.Column("conversation_id", models.types.StringUUID(), nullable=False),
sa.Column(
"draft_type",
sa.String(length=32),
nullable=False,
server_default=sa.text("'debug_build'"),
),
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=op.f("agent_debug_conversation_pkey")),
sa.UniqueConstraint(
"tenant_id",
"agent_id",
"account_id",
"draft_type",
name=op.f("agent_debug_conversation_agent_account_draft_type_unique"),
),
)
op.create_index(
"agent_debug_conversation_conversation_idx",
"agent_debug_conversations",
["conversation_id"],
)
op.create_index(
"agent_debug_conversation_account_idx",
"agent_debug_conversations",
["tenant_id", "account_id"],
)
return
# Existing pointers have always represented Build chat because the Agent
# detail API exposes them as ``debug_conversation_id`` for that surface.
if not _has_column("agent_debug_conversations", "draft_type"):
op.add_column(
"agent_debug_conversations",
sa.Column(
"draft_type",
sa.String(length=32),
nullable=False,
server_default=sa.text("'debug_build'"),
),
)
if _has_unique_constraint(
op.add_column(
"agent_debug_conversations",
sa.Column(
"draft_type",
sa.String(length=32),
nullable=False,
server_default=sa.text("'debug_build'"),
),
)
op.drop_constraint(
"agent_debug_conversation_agent_account_unique",
):
op.drop_constraint(
"agent_debug_conversation_agent_account_unique",
"agent_debug_conversations",
type_="unique",
)
if not _has_unique_constraint(
"agent_debug_conversations",
type_="unique",
)
op.create_unique_constraint(
"agent_debug_conversation_agent_account_draft_type_unique",
):
op.create_unique_constraint(
"agent_debug_conversation_agent_account_draft_type_unique",
"agent_debug_conversations",
["tenant_id", "agent_id", "account_id", "draft_type"],
)
"agent_debug_conversations",
["tenant_id", "agent_id", "account_id", "draft_type"],
)
def downgrade():
if not _has_table("agent_debug_conversations"):
return
debug_conversations = sa.table(
"agent_debug_conversations",
sa.column("tenant_id", models.types.StringUUID()),
@@ -142,23 +64,14 @@ def downgrade():
),
)
)
if _has_unique_constraint(
"agent_debug_conversations",
op.drop_constraint(
"agent_debug_conversation_agent_account_draft_type_unique",
):
op.drop_constraint(
"agent_debug_conversation_agent_account_draft_type_unique",
"agent_debug_conversations",
type_="unique",
)
if not _has_unique_constraint(
"agent_debug_conversations",
type_="unique",
)
op.create_unique_constraint(
"agent_debug_conversation_agent_account_unique",
):
op.create_unique_constraint(
"agent_debug_conversation_agent_account_unique",
"agent_debug_conversations",
["tenant_id", "agent_id", "account_id"],
)
if _has_column("agent_debug_conversations", "draft_type"):
op.drop_column("agent_debug_conversations", "draft_type")
"agent_debug_conversations",
["tenant_id", "agent_id", "account_id"],
)
op.drop_column("agent_debug_conversations", "draft_type")
@@ -1,21 +0,0 @@
"""merge skill and agent debug conversation heads
Revision ID: e9f4a1b2c3d5
Revises: a4f8d2c9e1b0, d2825e7b9c10
Create Date: 2026-07-23 15:00:00.000000
"""
# revision identifiers, used by Alembic.
revision = "e9f4a1b2c3d5"
down_revision = ("a4f8d2c9e1b0", "d2825e7b9c10")
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass
@@ -0,0 +1,141 @@
"""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")
@@ -0,0 +1,320 @@
"""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")
@@ -0,0 +1,290 @@
"""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")
@@ -0,0 +1,105 @@
"""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")
@@ -0,0 +1,183 @@
"""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")
+86 -7
View File
@@ -66,6 +66,50 @@ 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,
@@ -113,7 +157,6 @@ from .provider import (
TenantDefaultModel,
TenantPreferredModelProvider,
)
from .skill import AgentSkillBinding, Skill, SkillDraftFile, SkillFileKind, SkillFileStorage, SkillVersion
from .snippet import CustomizedSnippet, SnippetType
from .source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
from .task import CeleryTask, CeleryTaskSet
@@ -156,6 +199,7 @@ __all__ = [
"APIBasedExtensionPoint",
"Account",
"AccountIntegrate",
"AccountSessionAuthorizationProof",
"AccountStatus",
"AccountStepByStepTourState",
"AccountTrialAppRecord",
@@ -174,7 +218,6 @@ __all__ = [
"AgentRuntimeSessionOwnerType",
"AgentRuntimeSessionStatus",
"AgentScope",
"AgentSkillBinding",
"AgentSource",
"AgentStatus",
"ApiRequest",
@@ -213,22 +256,59 @@ __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",
@@ -246,13 +326,10 @@ __all__ = [
"ProviderQuotaType",
"ProviderType",
"RecommendedApp",
"ResendEmailProviderEncryptedCredentials",
"SavedMessage",
"Site",
"Skill",
"SkillDraftFile",
"SkillFileKind",
"SkillFileStorage",
"SkillVersion",
"SlackIMIntegrationEncryptedCredentials",
"SnippetType",
"Tag",
"TagBinding",
@@ -273,7 +350,9 @@ __all__ = [
"TriggerOAuthSystemClient",
"TriggerOAuthTenantClient",
"TriggerSubscription",
"TrustedEndUserAuthorizationProof",
"UploadFile",
"WeComIMIntegrationEncryptedCredentials",
"Whitelist",
"Workflow",
"WorkflowAgentBindingType",
+1 -1
View File
@@ -2,7 +2,7 @@ from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import MetaData
POSTGRES_INDEXES_NAMING_CONVENTION = {
"ix": "%(column_0_label)s_idx",
"ix": "%(table_name)s_%(column_0_N_name)s_idx",
"uq": "%(table_name)s_%(column_0_name)s_key",
"ck": "%(table_name)s_%(constraint_name)s_check",
"fk": "%(table_name)s_%(column_0_name)s_fkey",
-1
View File
@@ -249,7 +249,6 @@ class TagType(StrEnum):
KNOWLEDGE = "knowledge"
APP = "app"
SNIPPET = "snippet"
SKILL = "skill"
class DatasetMetadataType(StrEnum):
+25 -2
View File
@@ -3,10 +3,10 @@ from enum import StrEnum
from typing import Annotated, Literal, Self, final
import sqlalchemy as sa
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.workflow.human_input_adapter import DeliveryMethodType
from core.workflow.human_input_adapter import DeliveryMethodType, InstantMessageProvider, InstantMessageRecipientType
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
from libs.helper import generate_string
@@ -138,6 +138,7 @@ class ApprovalChannel(StrEnum):
"""Where a paused human input form can be approved, surfaced to API callers."""
EMAIL = "email"
IM = "im"
WEB_APP = "web_app"
CONSOLE = "console"
@@ -146,6 +147,7 @@ class RecipientType(StrEnum):
# Second value = the approval channel this recipient maps to (surfaced in `approval_channels`).
EMAIL_MEMBER = "email_member", ApprovalChannel.EMAIL
EMAIL_EXTERNAL = "email_external", ApprovalChannel.EMAIL
INSTANT_MESSAGE = "instant_message", ApprovalChannel.IM
# STANDALONE_WEB_APP is used by the standalone web app.
#
# It's not used while running workflows / chatflows containing HumanInput
@@ -185,6 +187,26 @@ class EmailExternalRecipientPayload(BaseModel):
email: str
@final
class InstantMessageRecipientPayload(BaseModel):
TYPE: Literal[RecipientType.INSTANT_MESSAGE] = RecipientType.INSTANT_MESSAGE
provider: InstantMessageProvider
recipient_kind: InstantMessageRecipientType
channel_id: str | None = None
user_id: str | None = None
@model_validator(mode="after")
def validate_target(self) -> Self:
match self.recipient_kind:
case InstantMessageRecipientType.CHANNEL:
if self.channel_id is None or self.user_id is not None:
raise ValueError("channel recipients must include channel_id and must not include user_id")
case InstantMessageRecipientType.USER:
if self.user_id is None or self.channel_id is not None:
raise ValueError("user recipients must include user_id and must not include channel_id")
return self
@final
class StandaloneWebAppRecipientPayload(BaseModel):
TYPE: Literal[RecipientType.STANDALONE_WEB_APP] = RecipientType.STANDALONE_WEB_APP
@@ -211,6 +233,7 @@ class ConsoleDeliveryPayload(BaseModel):
RecipientPayload = Annotated[
EmailMemberRecipientPayload
| EmailExternalRecipientPayload
| InstantMessageRecipientPayload
| StandaloneWebAppRecipientPayload
| ConsoleRecipientPayload
| BackstageRecipientPayload,
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2667,7 +2667,7 @@ class Tag(TypeBase):
sa.Index("tag_name_idx", "name"),
)
TAG_TYPE_LIST = ["knowledge", "app", "snippet", "skill"]
TAG_TYPE_LIST = ["knowledge", "app", "snippet"]
id: Mapped[str] = mapped_column(
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
-164
View File
@@ -1,164 +0,0 @@
"""Workspace-level Skill Management models.
These tables are the source of truth for reusable workspace Skills. Agent Soul
``config_skills`` and Agent Drive skill rows remain per-agent runtime/config
assets; they may consume a published Skill snapshot but do not own the Skill's
draft, metadata, version history, or Agent binding priority.
"""
from enum import StrEnum
import sqlalchemy as sa
from pydantic import BaseModel, ConfigDict
from sqlalchemy import Index, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from models.base import Base, DefaultFieldsMixin
from models.types import EnumText, JSONModelColumn, LongText, StringUUID
class SkillFileKind(StrEnum):
"""Draft file entry kind."""
FILE = "file"
DIRECTORY = "directory"
class SkillFileStorage(StrEnum):
"""How a draft file's content is stored."""
TEXT = "text"
TOOL_FILE = "tool_file"
class SkillVersionManifestFile(BaseModel):
"""One file entry captured in a published Skill snapshot manifest."""
path: str
mime_type: str | None = None
size: int
hash: str
model_config = ConfigDict(extra="forbid")
class SkillVersionManifest(BaseModel):
"""Published Skill snapshot file index."""
files: list[SkillVersionManifestFile]
model_config = ConfigDict(extra="forbid")
class Skill(DefaultFieldsMixin, Base):
"""Workspace-level reusable Skill metadata and draft status."""
__tablename__ = "skills"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="skill_pkey"),
UniqueConstraint("tenant_id", "name", name="skill_tenant_name_unique"),
Index("skills_tenant_updated_at_idx", "tenant_id", "updated_at"),
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
name: Mapped[str] = mapped_column(sa.String(64), nullable=False)
display_name: Mapped[str] = mapped_column(sa.String(128), nullable=False)
icon: Mapped[str] = mapped_column(sa.String(16), nullable=False, default="📄", server_default="📄")
description: Mapped[str] = mapped_column(sa.String(1024), nullable=False, default="", server_default="")
name_manually_edited: Mapped[bool] = mapped_column(
sa.Boolean,
nullable=False,
default=False,
server_default=sa.false(),
)
visibility: Mapped[str] = mapped_column(
sa.String(32),
nullable=False,
default="workspace",
server_default="workspace",
)
latest_published_version_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
class SkillDraftFile(DefaultFieldsMixin, Base):
"""One draft file or directory in a workspace Skill."""
__tablename__ = "skill_draft_files"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="skill_draft_file_pkey"),
UniqueConstraint("skill_id", "path", name="skill_draft_file_skill_path_unique"),
Index("skill_draft_files_skill_path_idx", "skill_id", "path"),
)
skill_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
path: Mapped[str] = mapped_column(sa.String(512), nullable=False)
kind: Mapped[SkillFileKind] = mapped_column(EnumText(SkillFileKind, length=32), nullable=False)
storage: Mapped[SkillFileStorage | None] = mapped_column(EnumText(SkillFileStorage, length=32), nullable=True)
mime_type: Mapped[str | None] = mapped_column(sa.String(255), nullable=True)
content_text: Mapped[str | None] = mapped_column(LongText, nullable=True)
tool_file_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
size: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True)
hash: Mapped[str | None] = mapped_column(sa.String(255), nullable=True)
class SkillVersion(DefaultFieldsMixin, Base):
"""Immutable published Skill snapshot.
``hash_code`` uniquely identifies a published version for downstream
execution audit. It includes Skill identity, version number, and archive
content digest instead of being only the archive content hash.
"""
__tablename__ = "skill_versions"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="skill_version_pkey"),
UniqueConstraint("skill_id", "version_number", name="skill_version_skill_number_unique"),
Index("skill_versions_skill_created_at_idx", "skill_id", "created_at"),
)
skill_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
version_number: Mapped[int] = mapped_column(sa.Integer, nullable=False)
version_name: Mapped[str] = mapped_column(sa.String(128), nullable=False, default="", server_default="")
publish_note: Mapped[str] = mapped_column(sa.String(1024), nullable=False, default="", server_default="")
manifest: Mapped[SkillVersionManifest] = mapped_column(JSONModelColumn(SkillVersionManifest), nullable=False)
archive_tool_file_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
hash_code: Mapped[str] = mapped_column(sa.String(255), nullable=False)
archive_size: Mapped[int] = mapped_column(sa.BigInteger, nullable=False)
published_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
class AgentSkillBinding(DefaultFieldsMixin, Base):
"""Direct Agent-to-workspace-Skill binding.
``priority`` is retained as an internal ordering column for the current
schema constraints. Runtime Skill selection is Agent-driven and must not
treat it as a matching priority.
"""
__tablename__ = "agent_skill_bindings"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="agent_skill_binding_pkey"),
UniqueConstraint("tenant_id", "agent_id", "skill_id", name="agent_skill_binding_unique"),
UniqueConstraint("tenant_id", "agent_id", "priority", name="agent_skill_binding_priority_unique"),
Index("agent_skill_bindings_skill_idx", "tenant_id", "skill_id"),
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
skill_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
priority: Mapped[int] = mapped_column(sa.Integer, nullable=False)
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
__all__ = [
"AgentSkillBinding",
"Skill",
"SkillDraftFile",
"SkillFileKind",
"SkillFileStorage",
"SkillVersion",
"SkillVersionManifest",
"SkillVersionManifestFile",
]
+132 -3
View File
@@ -1,15 +1,16 @@
import enum
import json
import uuid
from typing import Any, cast, override
from typing import Any, cast, overload, override
import sqlalchemy as sa
from pydantic import BaseModel
from pydantic import BaseModel, TypeAdapter
from sqlalchemy import CHAR, TEXT, VARCHAR, LargeBinary, TypeDecorator
from sqlalchemy.dialects.mysql import LONGBLOB, LONGTEXT
from sqlalchemy.dialects.postgresql import BYTEA, JSONB, UUID
from sqlalchemy.engine.interfaces import Dialect
from sqlalchemy.sql.type_api import TypeEngine
from typing_extensions import deprecated
from configs import dify_config
@@ -69,8 +70,24 @@ class LongText(TypeDecorator[str | None]):
return value
@deprecated(
"JSONModelColumn is a legacy coercing TEXT-backed type. "
"Use FrozenPydanticModelColumn for new model-only persistence."
)
class JSONModelColumn[T: BaseModel](TypeDecorator[T | None]):
"""Store a Pydantic model as dialect-adjusted LongText JSON."""
"""Store coercible Pydantic model input as dialect-adjusted LongText JSON.
New model-only columns should use ``FrozenPydanticModelColumn``. Existing
uses must not be replaced mechanically because they may depend on:
- historical ``dict`` or JSON string writes;
- non-strict validation and coercion;
- Pydantic models that are not frozen;
- compatibility with dirty or otherwise non-canonical stored data.
Audit both callers and persisted values before migrating an existing
column.
"""
impl = TEXT
cache_ok = True
@@ -171,6 +188,118 @@ class AdjustedJSON(TypeDecorator[dict | list | None]):
return value
class FrozenPydanticModelColumn[T: BaseModel](TypeDecorator[T]):
"""Persist a frozen Pydantic model as JSON in a dialect-adjusted text column.
Binding serializes the accepted model directly with
``model_dump_json(warnings="error")``. Loading validates the stored JSON in
strict mode and lets Pydantic validation errors propagate to the caller.
Pass a concrete model class for concrete models. For discriminated unions,
pass a ``TypeAdapter`` together with every allowed concrete model class.
The backing type is ``TEXT`` on PostgreSQL and SQLite and ``LONGTEXT`` on
MySQL. This is intentional: ``model_dump_json`` already returns JSON text,
while passing that string through SQLAlchemy's ``JSON`` type would encode it
again and persist a JSON string scalar instead of the model's JSON object.
SQLAlchemy does not track in-place changes made inside a Pydantic model, so
this type only accepts models configured with ``frozen=True``. Persisted
values must be updated by constructing a new model and assigning it to the
ORM attribute as a whole, for example ``record.payload = PayloadModel(...)``.
Otherwise the attribute may not be marked dirty and the change may not be
persisted.
Pydantic freezing is shallow: nested mutable containers such as ``dict`` and
``list`` can still be changed in place. Callers must treat nested values as
immutable too instead of mutating paths such as
``record.payload.root["key"]``. Supporting nested in-place mutation would
require SQLAlchemy's Mutable extension or explicit deep change tracking,
which this type does not provide.
"""
impl = TEXT
cache_ok = True
_model_type: type[T] | None
_adapter: TypeAdapter[T] | None
model_types: tuple[type[BaseModel], ...]
@overload
def __init__(
self,
model_type: type[T],
/,
) -> None: ...
@overload
def __init__(
self,
adapter: TypeAdapter[T],
/,
*,
model_types: tuple[type[BaseModel], ...],
) -> None: ...
def __init__(
self,
schema: type[T] | TypeAdapter[T],
/,
*,
model_types: tuple[type[BaseModel], ...] | None = None,
) -> None:
if isinstance(schema, TypeAdapter):
if model_types is None:
raise TypeError("model_types is required when schema is a TypeAdapter")
if not model_types:
raise ValueError("model_types must not be empty")
self._model_type = None
self._adapter = schema
self.model_types = model_types
else:
if model_types is not None:
raise TypeError("model_types must not be provided for a concrete model")
self._model_type = schema
self._adapter = None
self.model_types = (schema,)
for allowed_model_type in self.model_types:
if not isinstance(allowed_model_type, type) or not issubclass(allowed_model_type, BaseModel):
raise TypeError("model_types must contain only Pydantic BaseModel classes")
model_name = f"{allowed_model_type.__module__}.{allowed_model_type.__qualname__}"
if allowed_model_type.model_config.get("frozen") is not True:
raise TypeError(f"{model_name} must configure frozen=True")
if allowed_model_type.model_config.get("strict") is not True:
raise TypeError(f"{model_name} must configure strict=True")
super().__init__()
@override
def load_dialect_impl(self, dialect: Dialect) -> TypeEngine[Any]:
if dialect.name == "mysql":
return dialect.type_descriptor(LONGTEXT())
return dialect.type_descriptor(TEXT())
@override
def process_bind_param(self, value: object | None, dialect: Dialect) -> str | None:
del dialect
if value is None:
return None
if not isinstance(value, BaseModel) or not isinstance(value, self.model_types):
allowed_model_names = ", ".join(model_type.__name__ for model_type in self.model_types)
raise TypeError(f"value must be one of these Pydantic models: {allowed_model_names}")
return value.model_dump_json(warnings="error")
@override
def process_result_value(self, value: str | bytes | bytearray | None, dialect: Dialect) -> T | None:
del dialect
if value is None:
return None
if self._model_type is not None:
return self._model_type.model_validate_json(value, strict=True)
if self._adapter is None:
raise RuntimeError("Pydantic JSON validator is not configured")
return self._adapter.validate_json(value, strict=True)
class EnumText[T: enum.StrEnum](TypeDecorator[T | None]):
impl = VARCHAR
cache_ok = True
+2 -728
View File
@@ -9551,7 +9551,7 @@ Remove one or more tag bindings from a target.
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| keyword | query | Search keyword | No | string |
| type | query | Tag type filter | No | string |
| type | query | Tag type filter | No | string, <br>**Available values:** "", "app", "knowledge", "snippet" |
#### Responses
@@ -10087,38 +10087,6 @@ Get list of available agent providers
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [AgentProviderListResponse](#agentproviderlistresponse)<br> |
### [GET] /workspaces/current/agents/{agent_id}/skills
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent Skill bindings | **application/json**: [AgentSkillBindingsResponse](#agentskillbindingsresponse)<br> |
### [PUT] /workspaces/current/agents/{agent_id}/skills
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [AgentSkillBindingsPayload](#agentskillbindingspayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent Skill bindings replaced | **application/json**: [AgentSkillBindingsResponse](#agentskillbindingsresponse)<br> |
### [GET] /workspaces/current/customized-snippets
**List customized snippets with pagination and search**
@@ -12020,341 +11988,6 @@ Returns permission flags that control workspace features like member invitations
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [WorkspaceAccessMatrix](#workspaceaccessmatrix)<br> |
### [GET] /workspaces/current/skills
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| keyword | query | Search keyword matching skill name or description. | No | string |
| limit | query | Number of items per page. | No | integer, <br>**Default:** 20 |
| page | query | Page number. | No | integer, <br>**Default:** 1 |
| tag | query | Skill tag filters. Repeat the parameter for multiple tags. | No | [ string ] |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workspace skills | **application/json**: [SkillListResponse](#skilllistresponse)<br> |
### [POST] /workspaces/current/skills
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillCreatePayload](#skillcreatepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Skill created | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
### [POST] /workspaces/current/skills/files/upload
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **multipart/form-data**: { **"file"**: binary }<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Skill draft file uploaded | **application/json**: [SkillFileUploadResponse](#skillfileuploadresponse)<br> |
### [POST] /workspaces/current/skills/import
Import a Skill zip package from multipart form field `file`.
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Skill imported | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
### [GET] /workspaces/current/skills/tags
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workspace Skill tags | **application/json**: [SkillTagListResponse](#skilltaglistresponse)<br> |
### [DELETE] /workspaces/current/skills/{skill_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillDeletePayload](#skilldeletepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill deleted | **application/json**: [SkillDeleteResponse](#skilldeleteresponse)<br> |
### [GET] /workspaces/current/skills/{skill_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill detail | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
### [PATCH] /workspaces/current/skills/{skill_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillMetadataPayload](#skillmetadatapayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill updated | **application/json**: [SkillResponse](#skillresponse)<br> |
### [POST] /workspaces/current/skills/{skill_id}/assist/messages
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillAssistMessagePayload](#skillassistmessagepayload)<br> |
#### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Skill Authoring assistant event stream |
### [POST] /workspaces/current/skills/{skill_id}/duplicate
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Skill duplicated | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
### [GET] /workspaces/current/skills/{skill_id}/export
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Published Skill zip archive |
### [PATCH] /workspaces/current/skills/{skill_id}/files
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillDraftFileOperationPayload](#skilldraftfileoperationpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Draft file operation applied | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
### [PUT] /workspaces/current/skills/{skill_id}/files
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillDraftTreePayload](#skilldrafttreepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Draft files replaced | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
### [GET] /workspaces/current/skills/{skill_id}/files/content
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| download | query | Return as an attachment when 1. | No | string |
| path | query | Skill file path relative to the Skill root. | Yes | string |
| version_id | query | Optional published version ID. Omit for current draft. | No | string |
| skill_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill file content | **application/json**: [BinaryFileResponse](#binaryfileresponse)<br> |
### [GET] /workspaces/current/skills/{skill_id}/files/preview
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| path | query | Skill file path relative to the Skill root. | Yes | string |
| version_id | query | Optional published version ID. Omit for current draft. | No | string |
| skill_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill file text preview | **application/json**: [SkillFilePreviewResponse](#skillfilepreviewresponse)<br> |
### [POST] /workspaces/current/skills/{skill_id}/publish
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillPublishPayload](#skillpublishpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill published | **application/json**: [SkillVersionResponse](#skillversionresponse)<br> |
### [GET] /workspaces/current/skills/{skill_id}/references
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill references | **application/json**: [SkillReferenceListResponse](#skillreferencelistresponse)<br> |
### [POST] /workspaces/current/skills/{skill_id}/restore
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillRestorePayload](#skillrestorepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill version restored | **application/json**: [SkillVersionResponse](#skillversionresponse)<br> |
### [GET] /workspaces/current/skills/{skill_id}/versions
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill versions | **application/json**: [SkillVersionListResponse](#skillversionlistresponse)<br> |
### [DELETE] /workspaces/current/skills/{skill_id}/versions/{version_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
| version_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill version deleted | **application/json**: [SkillVersionDeleteResponse](#skillversiondeleteresponse)<br> |
### [GET] /workspaces/current/skills/{skill_id}/versions/{version_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
| version_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill version detail | **application/json**: [SkillVersionDetailResponse](#skillversiondetailresponse)<br> |
### [PATCH] /workspaces/current/skills/{skill_id}/versions/{version_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
| version_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillVersionUpdatePayload](#skillversionupdatepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill version updated | **application/json**: [SkillVersionResponse](#skillversionresponse)<br> |
### [GET] /workspaces/current/tool-labels
#### Responses
@@ -15076,37 +14709,6 @@ Visibility and lifecycle scope of an Agent record.
| ---- | ---- | ----------- | -------- |
| result | string | | Yes |
#### AgentSkillBindingItemResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | Yes |
| display_name | string | | Yes |
| file_count | integer | | Yes |
| icon | string | | Yes |
| id | string | | Yes |
| latest_published_at | integer | | No |
| latest_published_version_id | string | | No |
| name | string | | Yes |
| priority | integer | | Yes |
| status | string | | Yes |
| tags | [ string ] | | No |
| updated_at | integer | | Yes |
#### AgentSkillBindingsPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| skill_ids | [ string ] | Ordered Skill IDs bound to the Agent. | No |
#### AgentSkillBindingsResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_id | string | | Yes |
| data | [ [AgentSkillBindingItemResponse](#agentskillbindingitemresponse) ] | | No |
| skill_ids | [ string ] | | No |
#### AgentSkillRefConfig
| Name | Type | Description | Required |
@@ -22063,186 +21665,6 @@ Simple provider entity response.
| title | string | | Yes |
| use_icon_as_answer_icon | boolean | | Yes |
#### SkillAssistAttachmentPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| mime_type | string | | No |
| name | string | | Yes |
| size | integer | | No |
| tool_file_id | string | | Yes |
#### SkillAssistMessagePayload
One user message and optional uploaded context for the read-only Skill Authoring assistant.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| attachments | [ [SkillAssistAttachmentPayload](#skillassistattachmentpayload) ] | | No |
| message | string | | Yes |
| model | [SkillAssistModelPayload](#skillassistmodelpayload) | | No |
#### SkillAssistModelPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| model | string | | Yes |
| model_settings | object | | No |
| plugin_id | string | | No |
| provider | string | | Yes |
#### SkillCreatePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | No |
| display_name | string | | No |
| icon | string, <br>**Default:** 📄 | | No |
| name | string | | No |
| tags | [ string ] | | No |
#### SkillDeletePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| confirmation_name | string | Required when deleting a referenced Skill. Must match the Skill name. | No |
#### SkillDeleteResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| deleted | boolean | | Yes |
| id | string | | Yes |
#### SkillDetailResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| created_at | integer | | Yes |
| created_by | string | | No |
| created_by_name | string | | No |
| description | string | | Yes |
| display_name | string | | Yes |
| files | [ [SkillFileResponse](#skillfileresponse) ] | | No |
| icon | string | | Yes |
| id | string | | Yes |
| latest_published_version_id | string | | No |
| name | string | | Yes |
| name_manually_edited | boolean | | No |
| reference_count | integer | | No |
| tags | [ string ] | | No |
| updated_at | integer | | Yes |
| updated_by | string | | No |
| updated_by_name | string | | No |
| visibility | string | | Yes |
#### SkillDraftFileOperation
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| SkillDraftFileOperation | string | | |
#### SkillDraftFileOperationPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| content | string | | No |
| expected_updated_at | integer | | No |
| hash | string | | No |
| mime_type | string | | No |
| operation | [SkillDraftFileOperation](#skilldraftfileoperation) | | Yes |
| path | string | | Yes |
| size | integer | | No |
| target_path | string | | No |
| tool_file_id | string | | No |
#### SkillDraftTreeItemPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| content | string | | No |
| hash | string | | No |
| kind | [SkillFileKind](#skillfilekind) | | No |
| mime_type | string | | No |
| path | string | | Yes |
| size | integer | | No |
| storage | [SkillFileStorage](#skillfilestorage) | | No |
| tool_file_id | string | | No |
#### SkillDraftTreePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| expected_updated_at | integer | | No |
| files | [ [SkillDraftTreeItemPayload](#skilldrafttreeitempayload) ] | | No |
#### SkillFileKind
Draft file entry kind.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| SkillFileKind | string | Draft file entry kind. | |
#### SkillFilePreviewResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| content | string | | Yes |
| hash | string | | Yes |
| mime_type | string | | Yes |
| path | string | | Yes |
| size | integer | | Yes |
#### SkillFileQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| path | string | Skill file path relative to the Skill root. | Yes |
| version_id | string | Optional published version ID. Omit for current draft. | No |
#### SkillFileResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| content | string | | No |
| hash | string | | No |
| id | string | | No |
| kind | string | | Yes |
| mime_type | string | | No |
| path | string | | Yes |
| size | integer | | No |
| storage | string | | No |
| tool_file_id | string | | No |
#### SkillFileStorage
How a draft file's content is stored.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| SkillFileStorage | string | How a draft file's content is stored. | |
#### SkillFileUploadResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| hash | string | | Yes |
| id | string | | Yes |
| mime_type | string | | Yes |
| name | string | | Yes |
| size | integer | | Yes |
#### SkillListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [SkillResponse](#skillresponse) ] | | No |
| has_more | boolean | | No |
| limit | integer, <br>**Default:** 20 | | No |
| page | integer, <br>**Default:** 1 | | No |
| total | integer | | No |
#### SkillManifest
Validated metadata extracted from a Skill package.
@@ -22256,91 +21678,6 @@ Validated metadata extracted from a Skill package.
| name | string | | Yes |
| size | integer | | Yes |
#### SkillMetadataPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| display_name | string | | No |
| expected_updated_at | integer | | No |
| icon | string | | No |
| tags | [ string ] | | No |
#### SkillPublishPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| publish_note | string | | No |
| version_name | string | | No |
#### SkillReferenceListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [SkillReferenceResponse](#skillreferenceresponse) ] | | No |
#### SkillReferenceResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_icon | string | | No |
| agent_icon_background | string | | No |
| agent_icon_type | string | | No |
| agent_id | string | | Yes |
| app_id | string | | No |
| display_name | string | | Yes |
| name | string | | Yes |
| node_id | string | | No |
| node_name | string | | No |
| type | string | | Yes |
| workflow_icon | string | | No |
| workflow_icon_background | string | | No |
| workflow_icon_type | string | | No |
| workflow_id | string | | No |
| workflow_name | string | | No |
| workflow_version | string | | No |
#### SkillResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| created_at | integer | | Yes |
| created_by | string | | No |
| created_by_name | string | | No |
| description | string | | Yes |
| display_name | string | | Yes |
| icon | string | | Yes |
| id | string | | Yes |
| latest_published_version_id | string | | No |
| name | string | | Yes |
| name_manually_edited | boolean | | No |
| reference_count | integer | | No |
| tags | [ string ] | | No |
| updated_at | integer | | Yes |
| updated_by | string | | No |
| updated_by_name | string | | No |
| visibility | string | | Yes |
#### SkillRestorePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| publish_note | string | | No |
| version_id | string | | Yes |
| version_name | string | | No |
#### SkillTagListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [SkillTagResponse](#skilltagresponse) ] | | No |
#### SkillTagResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| count | integer | | Yes |
| tag | string | | Yes |
#### SkillToolInferenceResult
| Name | Type | Description | Required |
@@ -22349,60 +21686,6 @@ Validated metadata extracted from a Skill package.
| inferable | boolean | | Yes |
| reason | string | | No |
#### SkillVersionDeleteResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| deleted | boolean | | Yes |
| id | string | | Yes |
| latest_published_version_id | string | | No |
#### SkillVersionDetailResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| archive_size | integer | | Yes |
| created_at | integer | | Yes |
| files | [ [SkillFileResponse](#skillfileresponse) ] | | No |
| hash_code | string | | Yes |
| id | string | | Yes |
| is_latest | boolean | | No |
| publish_note | string | | Yes |
| published_by | string | | No |
| published_by_name | string | | No |
| skill_id | string | | Yes |
| version_name | string | | Yes |
| version_number | integer | | Yes |
#### SkillVersionListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [SkillVersionResponse](#skillversionresponse) ] | | No |
#### SkillVersionResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| archive_size | integer | | Yes |
| created_at | integer | | Yes |
| hash_code | string | | Yes |
| id | string | | Yes |
| is_latest | boolean | | No |
| publish_note | string | | Yes |
| published_by | string | | No |
| published_by_name | string | | No |
| skill_id | string | | Yes |
| version_name | string | | Yes |
| version_number | integer | | Yes |
#### SkillVersionUpdatePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| publish_note | string | | No |
| version_name | string | | No |
#### SnippetDependencyCheckResponse
| Name | Type | Description | Required |
@@ -22870,7 +22153,7 @@ Non-sensitive bootstrap snapshot exposed before Console or Web authentication.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| keyword | string | Search keyword | No |
| type | [TagType](#tagtype)<br>string | Tag type filter | No |
| type | string, <br>**Available values:** "", "app", "knowledge", "snippet" | Tag type filter<br>*Enum:* `""`, `"app"`, `"knowledge"`, `"snippet"` | No |
#### TagListResponse
@@ -25056,15 +24339,6 @@ Workflow tool configuration
| ---- | ---- | ----------- | -------- |
| permission_keys | [ string ] | | No |
#### WorkspaceSkillsQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| keyword | string | Search keyword matching skill name or description. | No |
| limit | integer, <br>**Default:** 20 | Number of items per page. | No |
| page | integer, <br>**Default:** 1 | Page number. | No |
| tag | [ string ] | Skill tag filters. Repeat the parameter for multiple tags. | No |
#### WorkspaceTenantResultResponse
| Name | Type | Description | Required |
@@ -1,5 +1,3 @@
"""Unit tests for Aliyun trace utility transformations and database lookups."""
import json
from collections.abc import Mapping
from typing import Any, cast
@@ -27,13 +25,11 @@ from dify_trace_aliyun.utils import (
serialize_json_data,
)
from opentelemetry.trace import Link, StatusCode
from sqlalchemy.orm import Session
from core.rag.models.document import Document
from graphon.entities import WorkflowNodeExecution
from graphon.enums import WorkflowNodeExecutionStatus
from models import EndUser
from models.enums import EndUserType
def test_get_user_id_from_message_data_no_end_user(monkeypatch: pytest.MonkeyPatch):
@@ -44,40 +40,35 @@ def test_get_user_id_from_message_data_no_end_user(monkeypatch: pytest.MonkeyPat
assert get_user_id_from_message_data(message_data) == "account_id"
@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True)
def test_get_user_id_from_message_data_with_end_user(monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
def test_get_user_id_from_message_data_with_end_user(monkeypatch: pytest.MonkeyPatch):
message_data = MagicMock()
message_data.from_account_id = "account_id"
message_data.from_end_user_id = "end_user_id"
end_user_data = EndUser(
id="end_user_id",
tenant_id="tenant_id",
app_id="app_id",
type=EndUserType.BROWSER,
session_id="session_id",
)
sqlite3_session.add(end_user_data)
sqlite3_session.commit()
end_user_data = MagicMock(spec=EndUser)
end_user_data.session_id = "session_id"
mock_session = MagicMock()
mock_session.get.return_value = end_user_data
from dify_trace_aliyun.utils import db
monkeypatch.setattr(db, "session", sqlite3_session)
monkeypatch.setattr(db, "session", mock_session)
assert get_user_id_from_message_data(message_data) == "session_id"
@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True)
def test_get_user_id_from_message_data_end_user_not_found(
monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
) -> None:
def test_get_user_id_from_message_data_end_user_not_found(monkeypatch: pytest.MonkeyPatch):
message_data = MagicMock()
message_data.from_account_id = "account_id"
message_data.from_end_user_id = "end_user_id"
mock_session = MagicMock()
mock_session.get.return_value = None
from dify_trace_aliyun.utils import db
monkeypatch.setattr(db, "session", sqlite3_session)
monkeypatch.setattr(db, "session", mock_session)
assert get_user_id_from_message_data(message_data) == "account_id"
@@ -1,8 +1,5 @@
"""Unit tests for LangSmith trace translation with SQLite-backed lookups."""
import collections
from datetime import datetime, timedelta
from types import SimpleNamespace
from typing import override
from unittest.mock import MagicMock
@@ -14,7 +11,6 @@ from dify_trace_langsmith.entities.langsmith_trace_entity import (
LangSmithRunUpdateModel,
)
from dify_trace_langsmith.langsmith_trace import LangSmithDataTrace
from sqlalchemy.orm import Session
from core.ops.entities.trace_entity import (
DatasetRetrievalTraceInfo,
@@ -28,7 +24,6 @@ from core.ops.entities.trace_entity import (
)
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey
from models import EndUser
from models.enums import EndUserType
def _dt() -> datetime:
@@ -113,8 +108,7 @@ def test_trace_dispatch(trace_instance, monkeypatch: pytest.MonkeyPatch):
mocks["generate_name_trace"].assert_called_once_with(info)
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
# Setup trace info
workflow_data = MagicMock()
workflow_data.created_at = _dt()
@@ -143,10 +137,10 @@ def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3
workflow_data=workflow_data,
)
monkeypatch.setattr(
"dify_trace_langsmith.langsmith_trace.db",
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
)
# Mock dependencies
mock_session = MagicMock()
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session)
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
# Mock node executions
node_llm = MagicMock()
@@ -234,10 +228,7 @@ def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3
assert call_args[4].run_type == LangSmithRunType.retriever
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
def test_workflow_trace_no_start_time(
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
) -> None:
def test_workflow_trace_no_start_time(trace_instance, monkeypatch: pytest.MonkeyPatch):
workflow_data = MagicMock()
workflow_data.created_at = _dt()
workflow_data.finished_at = _dt() + timedelta(seconds=1)
@@ -265,10 +256,9 @@ def test_workflow_trace_no_start_time(
workflow_data=workflow_data,
)
monkeypatch.setattr(
"dify_trace_langsmith.langsmith_trace.db",
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
)
mock_session = MagicMock()
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session)
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
repo = MagicMock()
repo.get_by_workflow_execution.return_value = []
mock_factory = MagicMock()
@@ -281,10 +271,7 @@ def test_workflow_trace_no_start_time(
assert trace_instance.add_run.called
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
def test_workflow_trace_missing_app_id(
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
) -> None:
def test_workflow_trace_missing_app_id(trace_instance, monkeypatch: pytest.MonkeyPatch):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info.trace_id = "trace-1"
trace_info.message_id = None
@@ -300,17 +287,15 @@ def test_workflow_trace_missing_app_id(
trace_info.workflow_run_outputs = {}
trace_info.error = ""
monkeypatch.setattr(
"dify_trace_langsmith.langsmith_trace.db",
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
)
mock_session = MagicMock()
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session)
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
with pytest.raises(ValueError, match="No app_id found in trace_info metadata"):
trace_instance.workflow_trace(trace_info)
@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True)
def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
message_data = MagicMock()
message_data.id = "msg-1"
message_data.from_account_id = "acc-1"
@@ -336,19 +321,10 @@ def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_
message_file_data=MagicMock(url="file-url"),
)
end_user = EndUser(
id="end-user-1",
tenant_id="tenant-1",
app_id="app-1",
type=EndUserType.BROWSER,
session_id="session-id-123",
)
sqlite3_session.add(end_user)
sqlite3_session.commit()
monkeypatch.setattr(
"dify_trace_langsmith.langsmith_trace.db",
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
)
# Mock EndUser lookup
mock_end_user = MagicMock(spec=EndUser)
mock_end_user.session_id = "session-id-123"
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db.session.get", lambda model, pk: mock_end_user)
trace_instance.add_run = MagicMock()
@@ -545,13 +521,9 @@ def test_update_run_error(trace_instance):
trace_instance.update_run(update_data)
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
def test_workflow_trace_usage_extraction_error(
trace_instance,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
sqlite3_session: Session,
) -> None:
trace_instance, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
):
workflow_data = MagicMock()
workflow_data.created_at = _dt()
workflow_data.finished_at = _dt() + timedelta(seconds=1)
@@ -604,10 +576,8 @@ def test_workflow_trace_usage_extraction_error(
mock_factory = MagicMock()
mock_factory.create_workflow_node_execution_repository.return_value = repo
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.DifyCoreRepositoryFactory", mock_factory)
monkeypatch.setattr(
"dify_trace_langsmith.langsmith_trace.db",
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
)
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock())
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock())
trace_instance.add_run = MagicMock()
@@ -674,11 +644,9 @@ def _make_workflow_trace_info(
)
def _patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session: Session) -> None:
monkeypatch.setattr(
"dify_trace_langsmith.langsmith_trace.db",
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
)
def _patch_workflow_trace_deps(monkeypatch, trace_instance):
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock())
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
repo = MagicMock()
repo.get_by_workflow_execution.return_value = []
factory = MagicMock()
@@ -688,17 +656,14 @@ def _patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session: Ses
trace_instance.add_run = MagicMock()
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
def test_workflow_trace_id_uses_message_id_not_external(
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
) -> None:
def test_workflow_trace_id_uses_message_id_not_external(trace_instance, monkeypatch: pytest.MonkeyPatch):
"""Chatflow with external trace_id: LangSmith trace_id must be message_id, not external."""
trace_info = _make_workflow_trace_info(
message_id="msg-abc",
workflow_run_id="run-xyz",
trace_id="external-999",
)
_patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session)
_patch_workflow_trace_deps(monkeypatch, trace_instance)
trace_instance.workflow_trace(trace_info)
@@ -712,17 +677,14 @@ def test_workflow_trace_id_uses_message_id_not_external(
assert trace_info.metadata.get("external_trace_id") == "external-999"
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
def test_workflow_trace_id_pure_workflow_uses_run_id(
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
) -> None:
def test_workflow_trace_id_pure_workflow_uses_run_id(trace_instance, monkeypatch: pytest.MonkeyPatch):
"""Pure workflow (no message_id) with external trace_id: trace_id must be workflow_run_id."""
trace_info = _make_workflow_trace_info(
message_id=None,
workflow_run_id="run-xyz",
trace_id="external-999",
)
_patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session)
_patch_workflow_trace_deps(monkeypatch, trace_instance)
trace_instance.workflow_trace(trace_info)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "dify-api"
version = "1.16.1"
version = "1.16.0"
requires-python = "~=3.12.0"
dependencies = [
@@ -0,0 +1 @@
"""Persistence adapters for Human Input v2 bounded contexts."""
@@ -0,0 +1,15 @@
"""SQLAlchemy persistence adapter for Human Input v2 OTP proof sessions."""
from .repository import (
OTPChallengeAuditFact,
OTPChallengeAuditWriter,
OTPPersistenceError,
SQLAlchemyOTPChallengeRepository,
)
__all__ = [
"OTPChallengeAuditFact",
"OTPChallengeAuditWriter",
"OTPPersistenceError",
"SQLAlchemyOTPChallengeRepository",
]
@@ -0,0 +1,179 @@
"""Explicit mapping between OTP proof-session values and ORM records.
Hash material crosses only this persistence boundary. Public challenge and proof
serialization deliberately omit every code and token digest.
"""
from __future__ import annotations
from datetime import UTC, datetime
from hashlib import sha256
from core.human_input_v2.approval import (
ContactOTPSubject,
EmailAddressOTPSubject,
EmailOTPSubject,
FormRef,
OTPChallenge,
OTPCodeHash,
VerifiedEmailOTPProof,
)
from core.human_input_v2.entities import HumanInputApproverGrantSubjectType, HumanInputOTPChallengeStatus
from core.human_input_v2.shared import (
ApproverGrantId,
ContactId,
FormId,
NormalizedEmail,
OTPChallengeId,
UtcTimestamp,
WorkspaceId,
)
from models.human_input_v2 import EmailOTPAuthorizationProof, HumanInputV2FormOTPChallenge
def _timestamp(value: datetime) -> UtcTimestamp:
return UtcTimestamp(value.replace(tzinfo=UTC) if value.tzinfo is None else value)
def _email_hash(normalized_email: NormalizedEmail) -> str:
return sha256(str(normalized_email).encode()).hexdigest()
def challenge_to_record(challenge: OTPChallenge) -> HumanInputV2FormOTPChallenge:
"""Map one detached aggregate into a record without plaintext secrets."""
contact_id: str | None = None
if isinstance(challenge.subject, ContactOTPSubject):
subject_type = HumanInputApproverGrantSubjectType.CONTACT
contact_id = str(challenge.subject.contact_id)
else:
subject_type = HumanInputApproverGrantSubjectType.EMAIL_ADDRESS
record = HumanInputV2FormOTPChallenge(
tenant_id=str(challenge.ref.form_ref.workspace_id),
form_id=str(challenge.ref.form_ref.form_id),
approver_grant_id=str(challenge.ref.grant_ref.grant_id),
subject_type=subject_type,
contact_id=contact_id,
challenge_token_hash=challenge.challenge_token_hash,
code_hash=challenge.code_hash.encoded_value,
code_hash_algorithm=challenge.code_hash.algorithm,
email_hash=_email_hash(challenge.normalized_email),
email=str(challenge.normalized_email),
status=challenge.status,
expires_at=challenge.expires_at.value,
resend_after=challenge.resend_after.value,
send_count=challenge.send_count,
attempt_count=challenge.attempt_count,
verified_at=challenge.verified_at.value if challenge.verified_at is not None else None,
invalidated_at=challenge.invalidated_at.value if challenge.invalidated_at is not None else None,
)
record.id = str(challenge.ref.challenge_id)
record.created_at = challenge.created_at.value
record.updated_at = challenge.updated_at.value
return record
def challenge_from_record(record: HumanInputV2FormOTPChallenge) -> OTPChallenge:
"""Rebuild one aggregate while rejecting malformed or secret-unsafe rows."""
try:
normalized_email = NormalizedEmail(record.email)
except ValueError as error:
raise ValueError("OTP challenge record has an invalid email") from error
if record.email_hash != _email_hash(normalized_email):
raise ValueError("OTP challenge record email hash does not match its normalized email")
try:
code_hash = OTPCodeHash(record.code_hash, record.code_hash_algorithm)
except ValueError as error:
raise ValueError("OTP challenge record has invalid code hash metadata") from error
subject: EmailOTPSubject
if record.subject_type is HumanInputApproverGrantSubjectType.CONTACT:
if record.contact_id is None:
raise ValueError("contact OTP challenge record is missing contact_id")
subject = ContactOTPSubject(ContactId(record.contact_id))
elif record.subject_type is HumanInputApproverGrantSubjectType.EMAIL_ADDRESS:
if record.contact_id is not None:
raise ValueError("email-address OTP challenge record must not contain contact_id")
subject = EmailAddressOTPSubject(normalized_email)
else:
raise ValueError("OTP challenge record has an unsupported subject type")
if not isinstance(record.status, HumanInputOTPChallengeStatus):
raise ValueError("OTP challenge record has an unsupported status")
challenge_ref = (
FormRef(WorkspaceId(record.tenant_id), FormId(record.form_id))
.grant(ApproverGrantId(record.approver_grant_id))
.challenge(OTPChallengeId(record.id))
)
return OTPChallenge(
ref=challenge_ref,
subject=subject,
normalized_email=normalized_email,
challenge_token_hash=record.challenge_token_hash,
code_hash=code_hash,
status=record.status,
expires_at=_timestamp(record.expires_at),
resend_after=_timestamp(record.resend_after),
send_count=record.send_count,
attempt_count=record.attempt_count,
verified_at=_timestamp(record.verified_at) if record.verified_at is not None else None,
invalidated_at=_timestamp(record.invalidated_at) if record.invalidated_at is not None else None,
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def proof_to_record_value(proof: VerifiedEmailOTPProof) -> EmailOTPAuthorizationProof:
"""Serialize one verified proof without code, token, or hash material."""
contact_id: str | None = None
if isinstance(proof.subject, ContactOTPSubject):
subject_type = HumanInputApproverGrantSubjectType.CONTACT
contact_id = str(proof.subject.contact_id)
else:
subject_type = HumanInputApproverGrantSubjectType.EMAIL_ADDRESS
return EmailOTPAuthorizationProof(
otp_challenge_id=str(proof.challenge_ref.challenge_id),
workspace_id=str(proof.challenge_ref.form_ref.workspace_id),
form_id=str(proof.challenge_ref.form_ref.form_id),
approver_grant_id=str(proof.challenge_ref.grant_ref.grant_id),
subject_type=subject_type,
contact_id=contact_id,
verified_email=str(proof.normalized_email),
verified_at=proof.verified_at.value,
)
def proof_from_record_value(
record_value: EmailOTPAuthorizationProof,
*,
workspace_id: WorkspaceId,
) -> VerifiedEmailOTPProof:
"""Rebuild one proof value after validating its captured subject shape."""
if record_value.workspace_id != str(workspace_id):
raise ValueError("OTP proof owner does not match the requested workspace")
normalized_email = NormalizedEmail(record_value.verified_email)
subject: EmailOTPSubject
if record_value.subject_type is HumanInputApproverGrantSubjectType.CONTACT:
if record_value.contact_id is None:
raise ValueError("contact OTP proof is missing contact_id")
subject = ContactOTPSubject(ContactId(record_value.contact_id))
elif record_value.subject_type is HumanInputApproverGrantSubjectType.EMAIL_ADDRESS:
if record_value.contact_id is not None:
raise ValueError("email-address OTP proof must not contain contact_id")
subject = EmailAddressOTPSubject(normalized_email)
else:
raise ValueError("OTP proof has an unsupported subject type")
challenge_ref = (
FormRef(workspace_id, FormId(record_value.form_id))
.grant(ApproverGrantId(record_value.approver_grant_id))
.challenge(OTPChallengeId(record_value.otp_challenge_id))
)
return VerifiedEmailOTPProof(
challenge_ref=challenge_ref,
subject=subject,
normalized_email=normalized_email,
verified_at=_timestamp(record_value.verified_at),
)
@@ -0,0 +1,361 @@
"""Grant-locked SQLAlchemy adapter for Email OTP proof sessions.
Every write locks the stable approver-grant row with its complete owner chain.
Replacement hashing completes before tracked rows are mutated; invalidation,
replacement insertion, and an injected audit append then share one short
transaction. The Submission Runtime persistence layer owns the concrete shared
form-audit writer and table; this module owns only the transaction-scoped port.
An elapsed pending challenge is committed as expired without hashing, audit, or
replacement side effects. The adapter never loads or writes Form lifecycle fields.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
import sqlalchemy as sa
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session, sessionmaker
from core.human_input_v2.approval import (
ApproverGrantRef,
Clock,
ContactOTPSubject,
CurrentEmailOTPIdentity,
EmailAddressOTPSubject,
OTPChallenge,
OTPChallengeRef,
OTPChallengeRejectionReason,
OTPCodeHasher,
OTPReplacementDecision,
OTPVerificationDecision,
)
from core.human_input_v2.entities import HumanInputApproverGrantSubjectType
from core.human_input_v2.shared import ContactId, NormalizedEmail, OTPChallengeId, UtcTimestamp
from models.human_input_v2 import (
HumanInputContact,
HumanInputV2FormApproverGrant,
HumanInputV2FormOTPChallenge,
)
from .mappers import challenge_from_record, challenge_to_record
class OTPPersistenceError(RuntimeError):
"""An OTP write failed before its complete transaction could commit."""
@dataclass(frozen=True, slots=True)
class OTPChallengeAuditFact:
"""Secret-free issuance fact appended in the replacement transaction."""
audit_event_id: str
challenge_ref: OTPChallengeRef
previous_challenge_id: OTPChallengeId | None
send_count: int
occurred_at: UtcTimestamp
class OTPChallengeAuditWriter(Protocol):
"""Append one audit fact through the caller-owned SQLAlchemy transaction.
The concrete shared audit mapping belongs to Submission Runtime persistence.
"""
def append(self, session: Session, fact: OTPChallengeAuditFact) -> None:
"""Add the fact without committing, flushing, or retaining the session."""
...
class SQLAlchemyOTPChallengeRepository:
"""Operation-oriented OTP adapter serialized by one grant row lock."""
_session_maker: sessionmaker[Session]
_clock: Clock
_code_hasher: OTPCodeHasher
_audit_writer: OTPChallengeAuditWriter
def __init__(
self,
session_maker: sessionmaker[Session],
*,
clock: Clock,
code_hasher: OTPCodeHasher,
audit_writer: OTPChallengeAuditWriter,
) -> None:
self._session_maker = session_maker
self._clock = clock
self._code_hasher = code_hasher
self._audit_writer = audit_writer
def issue_initial(
self,
grant_ref: ApproverGrantRef,
*,
challenge_id: OTPChallengeId,
audit_event_id: str,
challenge_token_hash: str,
plaintext_code: str,
) -> OTPChallenge:
"""Issue the first challenge for a grant scope and append its audit fact."""
try:
with self._session_maker() as session, session.begin():
grant_record = self._lock_grant(session, grant_ref)
if self._load_latest_record(session, grant_ref) is not None:
raise ValueError("OTP challenge already exists for the grant scope")
current_identity = self._current_identity(session, grant_record, grant_ref)
if current_identity.subject is None or current_identity.normalized_email is None:
raise ValueError("grant scope has no current Email identity")
challenge = OTPChallenge.issue(
challenge_ref=grant_ref.challenge(challenge_id),
subject=current_identity.subject,
normalized_email=current_identity.normalized_email,
challenge_token_hash=challenge_token_hash,
plaintext_code=plaintext_code,
send_count=1,
clock=self._clock,
code_hasher=self._code_hasher,
)
session.add(challenge_to_record(challenge))
self._append_audit(
session,
challenge=challenge,
previous_challenge_id=None,
audit_event_id=audit_event_id,
)
session.flush()
return challenge
except SQLAlchemyError as error:
raise OTPPersistenceError("failed to issue initial OTP challenge") from error
def replace_current(
self,
grant_ref: ApproverGrantRef,
*,
challenge_id: OTPChallengeId,
audit_event_id: str,
challenge_token_hash: str,
plaintext_code: str,
) -> OTPReplacementDecision:
"""Atomically expire or replace the latest challenge after policy checks."""
try:
with self._session_maker() as session, session.begin():
grant_record = self._lock_grant(session, grant_ref)
current_record = self._load_latest_record(session, grant_ref)
if current_record is None:
raise ValueError("current OTP challenge does not exist for the grant scope")
current = challenge_from_record(current_record)
current_identity = self._current_identity(session, grant_record, grant_ref)
if not self._identity_matches(current, current_identity):
invalidated = current.invalidate(clock=self._clock)
self._apply_state(current_record, invalidated)
session.flush()
return OTPReplacementDecision(
invalidated,
None,
OTPChallengeRejectionReason.STALE_IDENTITY,
)
decision = current.replace(
challenge_ref=grant_ref.challenge(challenge_id),
challenge_token_hash=challenge_token_hash,
plaintext_code=plaintext_code,
clock=self._clock,
code_hasher=self._code_hasher,
)
if decision.replacement is None:
if decision.previous != current:
self._apply_state(current_record, decision.previous)
session.flush()
return decision
self._apply_state(current_record, decision.previous)
session.add(challenge_to_record(decision.replacement))
self._append_audit(
session,
challenge=decision.replacement,
previous_challenge_id=current.ref.challenge_id,
audit_event_id=audit_event_id,
)
session.flush()
return decision
except SQLAlchemyError as error:
raise OTPPersistenceError("failed to replace current OTP challenge") from error
def verify(self, challenge_ref: OTPChallengeRef, *, plaintext_code: str) -> OTPVerificationDecision:
"""Verify one current challenge under its grant lock and persist counters."""
try:
with self._session_maker() as session, session.begin():
grant_record = self._lock_grant(session, challenge_ref.grant_ref)
record = self._load_record(session, challenge_ref)
if record is None:
raise ValueError("OTP challenge does not exist in the requested owner scope")
challenge = challenge_from_record(record)
current_identity = self._current_identity(session, grant_record, challenge_ref.grant_ref)
if not self._identity_matches(challenge, current_identity):
invalidated = challenge.invalidate(clock=self._clock)
self._apply_state(record, invalidated)
session.flush()
return OTPVerificationDecision(
invalidated,
None,
OTPChallengeRejectionReason.STALE_IDENTITY,
)
decision = challenge.verify(
plaintext_code=plaintext_code,
clock=self._clock,
code_hasher=self._code_hasher,
)
self._apply_state(record, decision.challenge)
session.flush()
return decision
except SQLAlchemyError as error:
raise OTPPersistenceError("failed to verify OTP challenge") from error
def invalidate_current(self, grant_ref: ApproverGrantRef) -> OTPChallenge | None:
"""Invalidate the latest challenge in one grant scope without Form mutation."""
try:
with self._session_maker() as session, session.begin():
self._lock_grant(session, grant_ref)
record = self._load_latest_record(session, grant_ref)
if record is None:
return None
invalidated = challenge_from_record(record).invalidate(clock=self._clock)
self._apply_state(record, invalidated)
session.flush()
return invalidated
except SQLAlchemyError as error:
raise OTPPersistenceError("failed to invalidate current OTP challenge") from error
def load(self, challenge_ref: OTPChallengeRef) -> OTPChallenge | None:
"""Load one challenge only when every owner predicate matches."""
try:
with self._session_maker() as session, session.begin():
record = self._load_record(session, challenge_ref)
return challenge_from_record(record) if record is not None else None
except SQLAlchemyError as error:
raise OTPPersistenceError("failed to load OTP challenge") from error
@staticmethod
def _locked_grant_statement(grant_ref: ApproverGrantRef) -> sa.Select[tuple[HumanInputV2FormApproverGrant]]:
return (
select(HumanInputV2FormApproverGrant)
.where(
HumanInputV2FormApproverGrant.tenant_id == str(grant_ref.form_ref.workspace_id),
HumanInputV2FormApproverGrant.form_id == str(grant_ref.form_ref.form_id),
HumanInputV2FormApproverGrant.id == str(grant_ref.grant_id),
)
.with_for_update()
)
def _lock_grant(self, session: Session, grant_ref: ApproverGrantRef) -> HumanInputV2FormApproverGrant:
grant_record = session.scalar(self._locked_grant_statement(grant_ref))
if grant_record is None:
raise ValueError("approver grant scope does not exist")
return grant_record
@staticmethod
def _load_latest_record(
session: Session,
grant_ref: ApproverGrantRef,
) -> HumanInputV2FormOTPChallenge | None:
return session.scalar(
select(HumanInputV2FormOTPChallenge)
.where(
HumanInputV2FormOTPChallenge.tenant_id == str(grant_ref.form_ref.workspace_id),
HumanInputV2FormOTPChallenge.form_id == str(grant_ref.form_ref.form_id),
HumanInputV2FormOTPChallenge.approver_grant_id == str(grant_ref.grant_id),
)
.order_by(HumanInputV2FormOTPChallenge.created_at.desc(), HumanInputV2FormOTPChallenge.id.desc())
.limit(1)
)
@staticmethod
def _load_record(session: Session, challenge_ref: OTPChallengeRef) -> HumanInputV2FormOTPChallenge | None:
return session.scalar(
select(HumanInputV2FormOTPChallenge).where(
HumanInputV2FormOTPChallenge.tenant_id == str(challenge_ref.form_ref.workspace_id),
HumanInputV2FormOTPChallenge.form_id == str(challenge_ref.form_ref.form_id),
HumanInputV2FormOTPChallenge.approver_grant_id == str(challenge_ref.grant_ref.grant_id),
HumanInputV2FormOTPChallenge.id == str(challenge_ref.challenge_id),
)
)
@staticmethod
def _current_identity(
session: Session,
grant_record: HumanInputV2FormApproverGrant,
grant_ref: ApproverGrantRef,
) -> CurrentEmailOTPIdentity:
if grant_record.subject_type is HumanInputApproverGrantSubjectType.CONTACT:
if grant_record.contact_id is None:
raise ValueError("contact grant is missing contact_id")
contact = session.scalar(
select(HumanInputContact).where(
HumanInputContact.id == grant_record.contact_id,
sa.or_(
HumanInputContact.tenant_id == str(grant_ref.form_ref.workspace_id),
HumanInputContact.tenant_id.is_(None),
),
)
)
if contact is None or contact.normalized_email is None:
return CurrentEmailOTPIdentity(grant_ref, None, None)
return CurrentEmailOTPIdentity(
grant_ref,
ContactOTPSubject(contact_id=ContactId(contact.id)),
NormalizedEmail(contact.normalized_email),
)
if grant_record.subject_type is HumanInputApproverGrantSubjectType.EMAIL_ADDRESS:
if grant_record.normalized_email is None:
raise ValueError("email-address grant is missing normalized_email")
normalized_email = NormalizedEmail(grant_record.normalized_email)
return CurrentEmailOTPIdentity(
grant_ref,
EmailAddressOTPSubject(normalized_email),
normalized_email,
)
raise ValueError("approver grant subject does not support Email OTP")
@staticmethod
def _identity_matches(challenge: OTPChallenge, current_identity: CurrentEmailOTPIdentity) -> bool:
return (
current_identity.subject is not None
and current_identity.normalized_email is not None
and challenge.subject == current_identity.subject
and challenge.normalized_email == current_identity.normalized_email
)
@staticmethod
def _apply_state(record: HumanInputV2FormOTPChallenge, challenge: OTPChallenge) -> None:
record.status = challenge.status
record.send_count = challenge.send_count
record.attempt_count = challenge.attempt_count
record.verified_at = challenge.verified_at.value if challenge.verified_at is not None else None
record.invalidated_at = challenge.invalidated_at.value if challenge.invalidated_at is not None else None
record.updated_at = challenge.updated_at.value
def _append_audit(
self,
session: Session,
*,
challenge: OTPChallenge,
previous_challenge_id: OTPChallengeId | None,
audit_event_id: str,
) -> None:
self._audit_writer.append(
session,
OTPChallengeAuditFact(
audit_event_id=audit_event_id,
challenge_ref=challenge.ref,
previous_challenge_id=previous_challenge_id,
send_count=challenge.send_count,
occurred_at=challenge.created_at,
),
)
@@ -0,0 +1,9 @@
"""SQLAlchemy mapping and transactions for the Contact Directory aggregate.
This package may depend on domain and ORM modules. It hides records, sessions,
owner predicates, locking, and rollback from domain and application callers.
"""
from .repository import SQLAlchemyContactDirectoryRepository
__all__ = ["SQLAlchemyContactDirectoryRepository"]
@@ -0,0 +1,124 @@
"""Explicit mapping between Contact Directory values and persistence records."""
from datetime import UTC, datetime
from core.human_input_v2.contact_directory import (
Contact,
ContactIdentitySource,
ContactOwner,
ExternalContactOwner,
OrganizationAccountOwner,
PlatformWorkspaceEntry,
WorkspaceMemberOwner,
)
from core.human_input_v2.shared import (
AccountId,
ContactId,
NormalizedEmail,
PlatformEntryId,
UtcTimestamp,
WorkspaceId,
)
from models.human_input_v2 import (
HumanInputContact,
HumanInputContactIdentitySource,
HumanInputPlatformContactWorkspaceEntry,
)
def _timestamp(value: datetime) -> UtcTimestamp:
"""Interpret database-naive timestamps as UTC, matching Dify persistence."""
return UtcTimestamp(value.replace(tzinfo=UTC) if value.tzinfo is None else value)
def contact_from_record(record: HumanInputContact) -> Contact:
"""Map one persistence record into an infrastructure-free Contact."""
identity_source = ContactIdentitySource(record.identity_source.value)
owner: ContactOwner
match identity_source:
case ContactIdentitySource.ORGANIZATION_ACCOUNT:
if record.account_id is None:
raise ValueError("organization account record is missing account_id")
owner = OrganizationAccountOwner(AccountId(record.account_id))
case ContactIdentitySource.WORKSPACE_MEMBER:
if record.tenant_id is None or record.account_id is None:
raise ValueError("workspace member record is missing owner fields")
owner = WorkspaceMemberOwner(WorkspaceId(record.tenant_id), AccountId(record.account_id))
case ContactIdentitySource.EXTERNAL:
if record.tenant_id is None:
raise ValueError("external contact record is missing tenant_id")
owner = ExternalContactOwner(WorkspaceId(record.tenant_id))
return Contact(
id=ContactId(record.id),
identity_source=identity_source,
owner=owner,
name=record.name,
normalized_name=record.normalized_name,
email=record.email,
normalized_email=NormalizedEmail(record.normalized_email) if record.normalized_email is not None else None,
avatar_file_id=record.avatar_file_id,
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def contact_to_record(contact: Contact) -> HumanInputContact:
"""Map one Contact into a detached persistence record."""
tenant_id: str | None
account_id: str | None
match contact.owner:
case OrganizationAccountOwner(account_id=owner_account_id):
tenant_id = None
account_id = str(owner_account_id)
case WorkspaceMemberOwner(workspace_id=workspace_id, account_id=owner_account_id):
tenant_id = str(workspace_id)
account_id = str(owner_account_id)
case ExternalContactOwner(workspace_id=workspace_id):
tenant_id = str(workspace_id)
account_id = None
record = HumanInputContact(
name=contact.name,
normalized_name=contact.normalized_name,
identity_source=HumanInputContactIdentitySource(contact.identity_source.value),
tenant_id=tenant_id,
account_id=account_id,
email=contact.email,
normalized_email=str(contact.normalized_email) if contact.normalized_email is not None else None,
avatar_file_id=contact.avatar_file_id,
)
record.id = str(contact.id)
record.created_at = contact.created_at.value
record.updated_at = contact.updated_at.value
return record
def platform_entry_from_record(record: HumanInputPlatformContactWorkspaceEntry) -> PlatformWorkspaceEntry:
"""Map one Platform allow-list record into a domain fact."""
return PlatformWorkspaceEntry(
id=PlatformEntryId(record.id),
workspace_id=WorkspaceId(record.tenant_id),
contact_id=ContactId(record.contact_id),
added_by_account_id=AccountId(record.added_by_account_id),
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def platform_entry_to_record(entry: PlatformWorkspaceEntry) -> HumanInputPlatformContactWorkspaceEntry:
"""Map one Platform allow-list fact into a detached persistence record."""
record = HumanInputPlatformContactWorkspaceEntry(
tenant_id=str(entry.workspace_id),
contact_id=str(entry.contact_id),
added_by_account_id=str(entry.added_by_account_id),
)
record.id = str(entry.id)
record.created_at = entry.created_at.value
record.updated_at = entry.updated_at.value
return record
@@ -0,0 +1,419 @@
"""SQLAlchemy Contact Directory adapter with aggregate-scoped transactions.
Every consuming query includes the complete deployment/workspace owner
predicate. EE Organization writes require the stable ``DifySetup`` row;
External writes share that lock when the deployment owner exists. ORM instances
never cross this boundary.
"""
from __future__ import annotations
from datetime import UTC, datetime
import sqlalchemy as sa
from sqlalchemy import or_, select
from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from sqlalchemy.orm import Session, selectinload, sessionmaker
from core.human_input_v2.contact_directory import (
Contact,
ContactDirectoryError,
ContactDirectoryPolicy,
ContactDirectorySnapshot,
ContactRejectionCode,
ExternalContactOwner,
OrganizationAccountOwner,
PlatformWorkspaceEntry,
WorkspaceMemberOwner,
)
from core.human_input_v2.shared import AccountId, ContactId, PlatformEntryId, UtcTimestamp, WorkspaceId
from libs.uuid_utils import uuidv7
from models.account import Account, AccountStatus, TenantAccountJoin
from models.human_input_v2 import (
HumanInputContact,
HumanInputContactIdentitySource,
HumanInputPlatformContactWorkspaceEntry,
)
from models.model import DifySetup
from .mappers import contact_from_record, contact_to_record, platform_entry_to_record
class SQLAlchemyContactDirectoryRepository:
"""Transactional adapter for coherent Contact Directory operations."""
_session_maker: sessionmaker[Session]
def __init__(self, session_maker: sessionmaker[Session]) -> None:
self._session_maker = session_maker
def load_snapshot(self, workspace_id: WorkspaceId) -> ContactDirectorySnapshot:
"""Load one coherent contacts, membership, allow-list, and Account view."""
try:
with self._session_maker() as session, session.begin():
self._configure_snapshot_transaction(session)
return self._load_snapshot(session, workspace_id)
except ContactDirectoryError:
raise
except SQLAlchemyError as error:
raise self._persistence_error() from error
def save_organization_contact(self, contact: Contact) -> Contact:
"""Persist one deployment-owned Organization Contact after a serialized identity claim."""
if not isinstance(contact.owner, OrganizationAccountOwner):
raise self._domain_error(ContactRejectionCode.INVALID_OWNER)
return self._save_account_backed_contact(contact, workspace_owner=None)
def save_workspace_member_contact(self, contact: Contact) -> Contact:
"""Persist one Contact only while its owning workspace membership exists."""
if not isinstance(contact.owner, WorkspaceMemberOwner):
raise self._domain_error(ContactRejectionCode.INVALID_OWNER)
return self._save_account_backed_contact(contact, workspace_owner=contact.owner)
def _save_account_backed_contact(
self,
contact: Contact,
*,
workspace_owner: WorkspaceMemberOwner | None,
) -> Contact:
"""Persist an already source-validated Account-backed Contact."""
try:
with self._session_maker() as session, session.begin():
if workspace_owner is None:
self._lock_deployment_owner(session, require_setup_row=True)
else:
self._ensure_workspace_membership(session, workspace_owner)
self._ensure_account_available(session, contact)
self._ensure_identity_available(session, contact)
record = self._find_owned_record(session, contact)
if record is None:
record = contact_to_record(contact)
session.add(record)
else:
if not self._record_has_same_identity(record, contact):
raise self._domain_error(ContactRejectionCode.INVALID_OWNER)
self._copy_mutable_values(record, contact)
session.flush()
return contact_from_record(record)
except ContactDirectoryError:
raise
except IntegrityError as error:
raise self._domain_error(ContactRejectionCode.CONFLICTING_IDENTITY) from error
except SQLAlchemyError as error:
raise self._persistence_error() from error
def admit_external(self, workspace_id: WorkspaceId, *, name: str, email: str) -> Contact:
"""Atomically validate and create a new External Contact.
External and Organization Email claims share the deployment lock when
deployment-wide Organization semantics exist. The lock is acquired
before the first Contact read so a waiter observes the transaction that
won the claim instead of retaining an older snapshot. SaaS deployments
without a setup row retain tenant-scoped External identity semantics.
"""
try:
with self._session_maker() as session, session.begin():
self._lock_deployment_owner(session, require_setup_row=False)
snapshot = self._load_snapshot(session, workspace_id)
contact = ContactDirectoryPolicy.admit_external(
snapshot,
contact_id=ContactId(str(uuidv7())),
name=name,
email=email,
now=UtcTimestamp(datetime.now(UTC)),
)
self._ensure_identity_available(session, contact)
record = contact_to_record(contact)
session.add(record)
session.flush()
return contact_from_record(record)
except ContactDirectoryError:
raise
except IntegrityError as error:
raise self._domain_error(ContactRejectionCode.CONFLICTING_IDENTITY) from error
except SQLAlchemyError as error:
raise self._persistence_error() from error
def set_platform_availability(
self,
workspace_id: WorkspaceId,
contact_id: ContactId,
*,
added_by_account_id: AccountId,
enabled: bool,
) -> None:
"""Idempotently mutate one workspace allow-list entry in a transaction."""
try:
with self._session_maker() as session, session.begin():
contact_record = session.scalar(
select(HumanInputContact).where(
HumanInputContact.id == str(contact_id),
or_(
HumanInputContact.tenant_id.is_(None),
HumanInputContact.tenant_id == str(workspace_id),
),
)
)
if contact_record is None:
raise self._domain_error(ContactRejectionCode.CONTACT_NOT_FOUND)
if (
contact_record.identity_source is not HumanInputContactIdentitySource.ORGANIZATION_ACCOUNT
or contact_record.tenant_id is not None
):
raise self._domain_error(ContactRejectionCode.INVALID_OWNER)
if enabled:
now = UtcTimestamp(datetime.now(UTC))
self._insert_platform_entry_idempotently(
session,
PlatformWorkspaceEntry(
id=PlatformEntryId(str(uuidv7())),
workspace_id=workspace_id,
contact_id=contact_id,
added_by_account_id=added_by_account_id,
created_at=now,
updated_at=now,
),
)
else:
session.execute(
sa.delete(HumanInputPlatformContactWorkspaceEntry).where(
HumanInputPlatformContactWorkspaceEntry.tenant_id == str(workspace_id),
HumanInputPlatformContactWorkspaceEntry.contact_id == str(contact_id),
)
)
session.flush()
except ContactDirectoryError:
raise
except IntegrityError as error:
raise self._domain_error(ContactRejectionCode.CONFLICTING_IDENTITY) from error
except SQLAlchemyError as error:
raise self._persistence_error() from error
def hard_delete_external(self, workspace_id: WorkspaceId, contact_id: ContactId) -> None:
"""Hard-delete an External Contact only within its owning workspace."""
try:
with self._session_maker() as session, session.begin():
record = session.scalar(
select(HumanInputContact).where(
HumanInputContact.id == str(contact_id),
HumanInputContact.tenant_id == str(workspace_id),
HumanInputContact.identity_source == HumanInputContactIdentitySource.EXTERNAL,
)
)
if record is None:
raise self._domain_error(ContactRejectionCode.CONTACT_NOT_FOUND)
session.delete(record)
except ContactDirectoryError:
raise
except SQLAlchemyError as error:
raise self._persistence_error() from error
def _load_snapshot(self, session: Session, workspace_id: WorkspaceId) -> ContactDirectorySnapshot:
contact_records = session.scalars(
select(HumanInputContact)
.options(
selectinload(
HumanInputContact.platform_workspace_entries.and_(
HumanInputPlatformContactWorkspaceEntry.tenant_id == str(workspace_id)
)
)
)
.where(
or_(
HumanInputContact.tenant_id.is_(None),
HumanInputContact.tenant_id == str(workspace_id),
)
)
.order_by(HumanInputContact.id)
).all()
contacts = tuple(contact_from_record(record) for record in contact_records)
member_account_ids = frozenset(
AccountId(account_id)
for account_id in session.scalars(
select(TenantAccountJoin.account_id).where(TenantAccountJoin.tenant_id == str(workspace_id))
).all()
)
contact_account_ids = {contact.account_id for contact in contacts if contact.account_id is not None}
active_account_ids = frozenset(
AccountId(account_id)
for account_id in session.scalars(
select(Account.id).where(
Account.id.in_([str(account_id) for account_id in contact_account_ids]),
Account.status == AccountStatus.ACTIVE,
)
).all()
)
platform_contact_ids = frozenset(
ContactId(record.id) for record in contact_records if record.platform_workspace_entries
)
return ContactDirectorySnapshot(
workspace_id=workspace_id,
contacts=contacts,
member_account_ids=member_account_ids,
platform_contact_ids=platform_contact_ids,
unavailable_account_ids=frozenset(contact_account_ids - active_account_ids),
)
@staticmethod
def _lock_deployment_owner(session: Session, *, require_setup_row: bool) -> None:
"""Lock the deployment identity owner when its semantics apply.
Organization writes require this owner. External writes use it when
present, while SaaS deployments without a deployment Organization keep
their existing tenant-local admission boundary.
"""
setup_version = session.scalars(select(DifySetup.version).with_for_update()).one_or_none()
if setup_version is None and require_setup_row:
raise SQLAlchemyContactDirectoryRepository._domain_error(ContactRejectionCode.SETUP_ROW_MISSING)
@staticmethod
def _configure_snapshot_transaction(session: Session) -> None:
"""Use one MVCC snapshot for all facts loaded by the directory query."""
if session.get_bind().dialect.name in {"mysql", "postgresql"}:
session.connection(execution_options={"isolation_level": "REPEATABLE READ"})
@staticmethod
def _ensure_workspace_membership(session: Session, owner: WorkspaceMemberOwner) -> None:
membership_id = session.scalar(
select(TenantAccountJoin.id)
.where(
TenantAccountJoin.tenant_id == str(owner.workspace_id),
TenantAccountJoin.account_id == str(owner.account_id),
)
.with_for_update()
)
if membership_id is None:
raise SQLAlchemyContactDirectoryRepository._domain_error(ContactRejectionCode.INVALID_OWNER)
@staticmethod
def _ensure_account_available(session: Session, contact: Contact) -> None:
account_id = contact.account_id
if account_id is None:
return
status = session.scalar(select(Account.status).where(Account.id == str(account_id)))
if status is not AccountStatus.ACTIVE:
raise SQLAlchemyContactDirectoryRepository._domain_error(ContactRejectionCode.ACCOUNT_UNAVAILABLE)
@staticmethod
def _ensure_identity_available(session: Session, contact: Contact) -> None:
identity_predicates: list[sa.ColumnElement[bool]] = []
if contact.account_id is not None:
identity_predicates.append(
sa.and_(
SQLAlchemyContactDirectoryRepository._owner_predicate(contact),
HumanInputContact.account_id == str(contact.account_id),
)
)
if contact.normalized_email is not None:
identity_predicates.append(
sa.and_(
SQLAlchemyContactDirectoryRepository._email_identity_scope_predicate(contact),
HumanInputContact.normalized_email == str(contact.normalized_email),
)
)
if not identity_predicates:
return
conflict_id = session.scalar(
select(HumanInputContact.id).where(
HumanInputContact.id != str(contact.id),
or_(*identity_predicates),
)
)
if conflict_id is not None:
raise SQLAlchemyContactDirectoryRepository._domain_error(ContactRejectionCode.CONFLICTING_IDENTITY)
@staticmethod
def _email_identity_scope_predicate(contact: Contact) -> sa.ColumnElement[bool]:
if isinstance(contact.owner, OrganizationAccountOwner):
return or_(
HumanInputContact.tenant_id.is_(None),
HumanInputContact.identity_source == HumanInputContactIdentitySource.EXTERNAL,
)
if isinstance(contact.owner, ExternalContactOwner):
return or_(
HumanInputContact.tenant_id == str(contact.owner.workspace_id),
sa.and_(
HumanInputContact.tenant_id.is_(None),
HumanInputContact.identity_source == HumanInputContactIdentitySource.ORGANIZATION_ACCOUNT,
),
)
return HumanInputContact.tenant_id == str(contact.owner.workspace_id)
@staticmethod
def _insert_platform_entry_idempotently(session: Session, entry: PlatformWorkspaceEntry) -> None:
record = platform_entry_to_record(entry)
values = {
"id": record.id,
"tenant_id": record.tenant_id,
"contact_id": record.contact_id,
"added_by_account_id": record.added_by_account_id,
"created_at": record.created_at,
"updated_at": record.updated_at,
}
dialect_name = session.get_bind().dialect.name
if dialect_name == "postgresql":
postgresql_statement = postgresql_insert(HumanInputPlatformContactWorkspaceEntry).values(**values)
session.execute(postgresql_statement.on_conflict_do_nothing(constraint="hipcwe_tenant_contact_uq"))
return
if dialect_name == "mysql":
mysql_statement = mysql_insert(HumanInputPlatformContactWorkspaceEntry).values(**values)
session.execute(mysql_statement.on_duplicate_key_update(contact_id=mysql_statement.inserted.contact_id))
return
if dialect_name == "sqlite":
sqlite_statement = sqlite_insert(HumanInputPlatformContactWorkspaceEntry).values(**values)
session.execute(sqlite_statement.on_conflict_do_nothing(index_elements=["tenant_id", "contact_id"]))
return
raise SQLAlchemyContactDirectoryRepository._persistence_error()
@staticmethod
def _find_owned_record(session: Session, contact: Contact) -> HumanInputContact | None:
return session.scalar(
select(HumanInputContact).where(
HumanInputContact.id == str(contact.id),
SQLAlchemyContactDirectoryRepository._owner_predicate(contact),
)
)
@staticmethod
def _owner_predicate(contact: Contact) -> sa.ColumnElement[bool]:
if isinstance(contact.owner, OrganizationAccountOwner):
return HumanInputContact.tenant_id.is_(None)
return HumanInputContact.tenant_id == str(contact.owner.workspace_id)
@staticmethod
def _record_has_same_identity(record: HumanInputContact, contact: Contact) -> bool:
expected_source = HumanInputContactIdentitySource(contact.identity_source.value)
expected_account_id = str(contact.account_id) if contact.account_id is not None else None
return record.identity_source is expected_source and record.account_id == expected_account_id
@staticmethod
def _copy_mutable_values(record: HumanInputContact, contact: Contact) -> None:
record.name = contact.name
record.normalized_name = contact.normalized_name
record.email = contact.email
record.normalized_email = str(contact.normalized_email) if contact.normalized_email is not None else None
record.avatar_file_id = contact.avatar_file_id
record.updated_at = contact.updated_at.value
@staticmethod
def _domain_error(code: ContactRejectionCode) -> ContactDirectoryError:
from core.human_input_v2.contact_directory import ContactRejection
return ContactDirectoryError(ContactRejection(code))
@staticmethod
def _persistence_error() -> ContactDirectoryError:
return SQLAlchemyContactDirectoryRepository._domain_error(ContactRejectionCode.PERSISTENCE_FAILURE)
@@ -0,0 +1,5 @@
"""SQLAlchemy persistence adapter for the Human Input v2 form aggregate."""
from .repository import FormPersistenceError, SQLAlchemyFormRepository
__all__ = ["FormPersistenceError", "SQLAlchemyFormRepository"]
@@ -0,0 +1,567 @@
"""Explicit mapping between Form Core domain values and ORM records.
Every structured JSON value is validated at this boundary. ORM records never
escape into the form domain, and mutable primitive containers are created only
for immediate Pydantic serialization.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import assert_never
from pydantic import JsonValue, TypeAdapter
from core.human_input_v2.approval import (
ApprovalSubject,
ApproverGrant,
CanonicalSubjectKey,
ConsoleEndpointConfiguration,
ContactApprovalSubject,
DeliveryAttempt,
DeliveryEndpoint,
DeliveryEndpointConfiguration,
EmailAddressApprovalSubject,
EmailEndpointConfiguration,
EmailProviderConfiguration,
EndpointAccessCapability,
EndUserApprovalSubject,
FormRef,
FrozenFormAction,
FrozenFormDefinition,
FrozenJSONObject,
HumanInputForm,
IMEndpointConfiguration,
MatchedRecipientSource,
SubjectSnapshot,
UploadCapability,
UploadFileAssociation,
WebEndpointConfiguration,
)
from core.human_input_v2.entities import HumanInputApproverGrantSubjectType, HumanInputDeliveryChannel
from core.human_input_v2.shared import (
AccountId,
AppId,
ApproverGrantId,
ContactId,
DeliveryAttemptId,
DeliveryEndpointId,
EmailProviderId,
EndUserId,
FormId,
IMBindingId,
IMIdentityId,
IntegrationId,
NormalizedEmail,
UploadCapabilityId,
UploadFileAssociationId,
UtcTimestamp,
WorkspaceId,
)
from core.workflow.nodes.human_input.entities import FormInputConfig, UserActionConfig
from models.base import DefaultFieldsDCMixin
from models.human_input_v2 import (
FormApproverGrantMatchedSource,
FormApproverGrantMatchedSources,
FormApproverGrantSubjectSnapshot,
FormDeliveryProviderResponse,
HumanInputEmailProvider,
HumanInputV2Form,
HumanInputV2FormApproverGrant,
HumanInputV2FormDefinition,
HumanInputV2FormDeliveryAttempt,
HumanInputV2FormDeliveryEndpoint,
HumanInputV2FormUploadFile,
HumanInputV2FormUploadToken,
ResendEmailProviderEncryptedCredentials,
)
_FORM_INPUT_ADAPTER: TypeAdapter[FormInputConfig] = TypeAdapter(FormInputConfig)
_JSON_OBJECT_ADAPTER: TypeAdapter[dict[str, JsonValue]] = TypeAdapter(dict[str, JsonValue])
def _timestamp(value: datetime) -> UtcTimestamp:
return UtcTimestamp(value.replace(tzinfo=UTC) if value.tzinfo is None else value)
def _set_record_identity(
record: DefaultFieldsDCMixin,
*,
record_id: str,
created_at: UtcTimestamp,
updated_at: UtcTimestamp,
) -> None:
# SQLAlchemy's mapped dataclass mixin owns these fields on every record type.
record.id = record_id
record.created_at = created_at.value
record.updated_at = updated_at.value
def _definition_to_record_value(definition: FrozenFormDefinition) -> HumanInputV2FormDefinition:
inputs = tuple(_FORM_INPUT_ADAPTER.validate_python(item.to_mapping()) for item in definition.inputs)
actions = tuple(
UserActionConfig.model_validate(
{"id": action.id, "title": action.title, "button_style": action.button_style},
)
for action in definition.actions
)
default_values = _JSON_OBJECT_ADAPTER.validate_python(definition.default_values.to_mapping())
return HumanInputV2FormDefinition(
form_content=definition.form_content,
inputs=inputs,
user_actions=actions,
default_values=default_values,
node_title=definition.node_title,
display_in_ui=definition.display_in_ui,
)
def _definition_from_record_value(definition: HumanInputV2FormDefinition) -> FrozenFormDefinition:
return FrozenFormDefinition(
form_content=definition.form_content,
inputs=tuple(
FrozenJSONObject.from_mapping(_JSON_OBJECT_ADAPTER.validate_python(item.model_dump(mode="json")))
for item in definition.inputs
),
actions=tuple(
FrozenFormAction(
id=action.id,
title=action.title,
button_style=action.button_style.value,
)
for action in definition.user_actions
),
default_values=FrozenJSONObject.from_mapping(definition.default_values),
node_title=definition.node_title,
display_in_ui=definition.display_in_ui,
)
def form_to_record(form: HumanInputForm) -> HumanInputV2Form:
"""Map one form root into a detached record without child side effects."""
record = HumanInputV2Form(
tenant_id=str(form.ref.workspace_id),
app_id=str(form.app_id),
form_definition=_definition_to_record_value(form.definition),
rendered_content=form.rendered_content,
node_timeout_at=form.node_timeout_at.value,
global_expires_at=form.global_expires_at.value,
form_kind=form.kind,
status=form.status,
workflow_pause_id=form.workflow_pause_id,
node_execution_id=form.node_execution_id,
)
_set_record_identity(
record,
record_id=str(form.ref.form_id),
created_at=form.created_at,
updated_at=form.updated_at,
)
return record
def form_from_record(
record: HumanInputV2Form,
grant_records: tuple[HumanInputV2FormApproverGrant, ...],
) -> HumanInputForm:
"""Rebuild one lifecycle-ready aggregate from an explicitly loaded grant graph."""
form_ref = FormRef(WorkspaceId(record.tenant_id), FormId(record.id))
grants = tuple(grant_from_record(grant_record) for grant_record in grant_records)
if any(grant.ref.form_ref != form_ref for grant in grants):
raise ValueError("loaded grant does not belong to the form record")
return HumanInputForm(
ref=form_ref,
app_id=AppId(record.app_id),
definition=_definition_from_record_value(record.form_definition),
rendered_content=record.rendered_content,
node_timeout_at=_timestamp(record.node_timeout_at),
global_expires_at=_timestamp(record.global_expires_at),
kind=record.form_kind,
status=record.status,
workflow_pause_id=record.workflow_pause_id,
node_execution_id=record.node_execution_id,
grants=grants,
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def grant_to_record(grant: ApproverGrant) -> HumanInputV2FormApproverGrant:
"""Map one historical grant into a detached persistence record."""
contact_id: str | None = None
end_user_id: str | None = None
normalized_email: str | None = None
match grant.subject:
case ContactApprovalSubject(contact_id=subject_contact_id):
subject_type = HumanInputApproverGrantSubjectType.CONTACT
contact_id = str(subject_contact_id)
case EndUserApprovalSubject(end_user_id=subject_end_user_id):
subject_type = HumanInputApproverGrantSubjectType.END_USER
end_user_id = str(subject_end_user_id)
case EmailAddressApprovalSubject(normalized_email=subject_email):
subject_type = HumanInputApproverGrantSubjectType.EMAIL_ADDRESS
normalized_email = str(subject_email)
case _:
assert_never(grant.subject)
record = HumanInputV2FormApproverGrant(
tenant_id=str(grant.ref.form_ref.workspace_id),
form_id=str(grant.ref.form_ref.form_id),
subject_type=subject_type,
subject_key=grant.subject_key.value,
matched_sources=FormApproverGrantMatchedSources(
sources=tuple(
FormApproverGrantMatchedSource(
kind=source.kind,
position=source.position,
reference=source.reference,
)
for source in grant.matched_sources
)
),
subject_snapshot=FormApproverGrantSubjectSnapshot(
display_name=grant.subject_snapshot.display_name,
email=grant.subject_snapshot.email,
),
contact_id=contact_id,
end_user_id=end_user_id,
normalized_email=normalized_email,
)
_set_record_identity(record, record_id=str(grant.id), created_at=grant.created_at, updated_at=grant.updated_at)
return record
def grant_from_record(record: HumanInputV2FormApproverGrant) -> ApproverGrant:
"""Map one validated grant record into a historical domain snapshot."""
subject: ApprovalSubject
match record.subject_type:
case HumanInputApproverGrantSubjectType.CONTACT:
if record.contact_id is None:
raise ValueError("contact grant record is missing contact_id")
subject = ContactApprovalSubject(ContactId(record.contact_id))
case HumanInputApproverGrantSubjectType.END_USER:
if record.end_user_id is None:
raise ValueError("end-user grant record is missing end_user_id")
subject = EndUserApprovalSubject(EndUserId(record.end_user_id))
case HumanInputApproverGrantSubjectType.EMAIL_ADDRESS:
if record.normalized_email is None:
raise ValueError("email grant record is missing normalized_email")
subject = EmailAddressApprovalSubject(NormalizedEmail(record.normalized_email))
return ApproverGrant(
ref=FormRef(WorkspaceId(record.tenant_id), FormId(record.form_id)).grant(ApproverGrantId(record.id)),
subject=subject,
subject_key=CanonicalSubjectKey(record.subject_key),
matched_sources=tuple(
MatchedRecipientSource(source.kind, source.position, source.reference)
for source in record.matched_sources.sources
),
subject_snapshot=SubjectSnapshot(
display_name=record.subject_snapshot.display_name,
email=record.subject_snapshot.email,
),
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def endpoint_to_record(endpoint: DeliveryEndpoint) -> HumanInputV2FormDeliveryEndpoint:
"""Map one discriminated endpoint configuration without raw dictionaries."""
email_address: str | None = None
integration_id: str | None = None
provider = None
provider_tenant_id: str | None = None
provider_user_id: str | None = None
im_identity_id: str | None = None
im_binding_id: str | None = None
match endpoint.configuration:
case EmailEndpointConfiguration(email_address=address):
email_address = str(address)
case IMEndpointConfiguration(
integration_id=integration,
provider=im_provider,
provider_tenant_id=tenant_identity,
identity_id=identity,
binding_id=binding,
provider_user_id=user_identity,
):
integration_id = str(integration)
provider = im_provider
provider_tenant_id = tenant_identity
provider_user_id = user_identity
im_identity_id = str(identity)
im_binding_id = str(binding) if binding is not None else None
case WebEndpointConfiguration() | ConsoleEndpointConfiguration():
pass
case _:
assert_never(endpoint.configuration)
record = HumanInputV2FormDeliveryEndpoint(
tenant_id=str(endpoint.ref.form_ref.workspace_id),
form_id=str(endpoint.ref.form_ref.form_id),
approver_grant_id=str(endpoint.ref.grant_ref.grant_id),
channel=endpoint.channel,
address_hash=endpoint.address_hash,
email_address=email_address,
integration_id=integration_id,
provider=provider,
provider_tenant_id=provider_tenant_id,
provider_user_id=provider_user_id,
im_identity_id=im_identity_id,
im_binding_id=im_binding_id,
access_token_hash=endpoint.access_capability.token_hash if endpoint.access_capability is not None else None,
)
_set_record_identity(
record, record_id=str(endpoint.id), created_at=endpoint.created_at, updated_at=endpoint.updated_at
)
return record
def endpoint_from_record(record: HumanInputV2FormDeliveryEndpoint) -> DeliveryEndpoint:
"""Reject malformed discriminated endpoint records instead of leaking nulls."""
endpoint_ref = (
FormRef(WorkspaceId(record.tenant_id), FormId(record.form_id))
.grant(ApproverGrantId(record.approver_grant_id))
.endpoint(DeliveryEndpointId(record.id))
)
configuration: DeliveryEndpointConfiguration
match record.channel:
case HumanInputDeliveryChannel.EMAIL:
if record.email_address is None:
raise ValueError("email endpoint record is missing email_address")
if (
record.integration_id is not None
or record.provider is not None
or record.provider_tenant_id is not None
or record.provider_user_id is not None
or record.im_identity_id is not None
or record.im_binding_id is not None
):
raise ValueError("email endpoint record contains invalid channel configuration")
configuration = EmailEndpointConfiguration(NormalizedEmail(record.email_address))
case HumanInputDeliveryChannel.IM:
if record.email_address is not None:
raise ValueError("IM endpoint record contains invalid channel configuration")
if (
record.integration_id is None
or record.provider is None
or record.provider_tenant_id is None
or record.im_identity_id is None
or record.provider_user_id is None
):
raise ValueError("IM endpoint record is missing provider configuration")
configuration = IMEndpointConfiguration(
integration_id=IntegrationId(record.integration_id),
provider=record.provider,
provider_tenant_id=record.provider_tenant_id,
identity_id=IMIdentityId(record.im_identity_id),
binding_id=IMBindingId(record.im_binding_id) if record.im_binding_id is not None else None,
provider_user_id=record.provider_user_id,
)
case HumanInputDeliveryChannel.WEB | HumanInputDeliveryChannel.CONSOLE:
if (
record.email_address is not None
or record.integration_id is not None
or record.provider is not None
or record.provider_tenant_id is not None
or record.provider_user_id is not None
or record.im_identity_id is not None
or record.im_binding_id is not None
):
raise ValueError(f"{record.channel.value} endpoint record contains invalid channel configuration")
configuration = (
WebEndpointConfiguration()
if record.channel is HumanInputDeliveryChannel.WEB
else ConsoleEndpointConfiguration()
)
case _:
raise ValueError(f"unsupported delivery channel: {record.channel!r}")
capability = (
EndpointAccessCapability(endpoint_ref, record.access_token_hash)
if record.access_token_hash is not None
else None
)
return DeliveryEndpoint(
ref=endpoint_ref,
configuration=configuration,
address_hash=record.address_hash,
access_capability=capability,
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def delivery_attempt_to_record(attempt: DeliveryAttempt) -> HumanInputV2FormDeliveryAttempt:
response = (
FormDeliveryProviderResponse(attempt.provider_response.to_mapping())
if attempt.provider_response is not None
else None
)
record = HumanInputV2FormDeliveryAttempt(
tenant_id=str(attempt.endpoint_ref.form_ref.workspace_id),
form_id=str(attempt.endpoint_ref.form_ref.form_id),
endpoint_id=str(attempt.endpoint_ref.endpoint_id),
attempt_number=attempt.attempt_number,
status=attempt.status,
scheduled_at=attempt.scheduled_at.value,
started_at=attempt.started_at.value if attempt.started_at is not None else None,
finished_at=attempt.finished_at.value if attempt.finished_at is not None else None,
provider_message_id=attempt.provider_message_id,
failure_code=attempt.failure_code,
failure_reason=attempt.failure_reason,
provider_response=response,
)
_set_record_identity(
record, record_id=str(attempt.id), created_at=attempt.created_at, updated_at=attempt.updated_at
)
return record
def delivery_attempt_from_record(
record: HumanInputV2FormDeliveryAttempt,
endpoint_record: HumanInputV2FormDeliveryEndpoint,
) -> DeliveryAttempt:
endpoint = endpoint_from_record(endpoint_record)
if (
endpoint.ref.form_ref.workspace_id != WorkspaceId(record.tenant_id)
or endpoint.ref.form_ref.form_id != FormId(record.form_id)
or endpoint.id != DeliveryEndpointId(record.endpoint_id)
):
raise ValueError("delivery attempt owner does not match its endpoint record")
return DeliveryAttempt(
id=DeliveryAttemptId(record.id),
endpoint_ref=endpoint.ref,
attempt_number=record.attempt_number,
status=record.status,
scheduled_at=_timestamp(record.scheduled_at),
started_at=_timestamp(record.started_at) if record.started_at is not None else None,
finished_at=_timestamp(record.finished_at) if record.finished_at is not None else None,
provider_message_id=record.provider_message_id,
failure_code=record.failure_code,
failure_reason=record.failure_reason,
provider_response=(
FrozenJSONObject.from_mapping(record.provider_response.root)
if record.provider_response is not None
else None
),
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def email_provider_to_record(provider: EmailProviderConfiguration) -> HumanInputEmailProvider:
credentials = ResendEmailProviderEncryptedCredentials.model_validate(
provider.encrypted_credentials.to_mapping(),
)
record = HumanInputEmailProvider(
provider=provider.provider,
sender_email=str(provider.sender_email),
encrypted_credentials=credentials,
tenant_id=str(provider.workspace_id),
sender_name=provider.sender_name,
configured_by_account_id=(
str(provider.configured_by_account_id) if provider.configured_by_account_id is not None else None
),
)
_set_record_identity(
record, record_id=str(provider.id), created_at=provider.created_at, updated_at=provider.updated_at
)
return record
def email_provider_from_record(record: HumanInputEmailProvider) -> EmailProviderConfiguration:
return EmailProviderConfiguration(
id=EmailProviderId(record.id),
workspace_id=WorkspaceId(record.tenant_id),
provider=record.provider,
sender_email=NormalizedEmail(record.sender_email),
sender_name=record.sender_name,
encrypted_credentials=FrozenJSONObject.from_mapping(
_JSON_OBJECT_ADAPTER.validate_python(record.encrypted_credentials.model_dump(mode="json"))
),
configured_by_account_id=(
AccountId(record.configured_by_account_id) if record.configured_by_account_id is not None else None
),
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def upload_capability_to_record(capability: UploadCapability) -> HumanInputV2FormUploadToken:
record = HumanInputV2FormUploadToken(
tenant_id=str(capability.endpoint_ref.form_ref.workspace_id),
app_id=str(capability.app_id),
form_id=str(capability.endpoint_ref.form_ref.form_id),
endpoint_id=str(capability.endpoint_ref.endpoint_id),
upload_token_hash=capability.token_hash,
)
_set_record_identity(
record, record_id=str(capability.id), created_at=capability.created_at, updated_at=capability.updated_at
)
return record
def upload_capability_from_record(
record: HumanInputV2FormUploadToken,
endpoint_record: HumanInputV2FormDeliveryEndpoint,
) -> UploadCapability:
endpoint = endpoint_from_record(endpoint_record)
if (
endpoint.ref.form_ref.workspace_id != WorkspaceId(record.tenant_id)
or endpoint.ref.form_ref.form_id != FormId(record.form_id)
or endpoint.id != DeliveryEndpointId(record.endpoint_id)
):
raise ValueError("upload capability owner does not match its endpoint record")
return UploadCapability(
id=UploadCapabilityId(record.id),
endpoint_ref=endpoint.ref,
app_id=AppId(record.app_id),
token_hash=record.upload_token_hash,
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def upload_file_to_record(association: UploadFileAssociation) -> HumanInputV2FormUploadFile:
endpoint_ref = association.capability_ref.endpoint_ref
record = HumanInputV2FormUploadFile(
tenant_id=str(endpoint_ref.form_ref.workspace_id),
app_id=str(association.capability_ref.app_id),
form_id=str(endpoint_ref.form_ref.form_id),
endpoint_id=str(endpoint_ref.endpoint_id),
upload_file_id=association.upload_file_id,
upload_token_id=str(association.capability_ref.capability_id),
)
_set_record_identity(
record,
record_id=str(association.id),
created_at=association.created_at,
updated_at=association.updated_at,
)
return record
def upload_file_from_record(
record: HumanInputV2FormUploadFile,
capability_record: HumanInputV2FormUploadToken,
endpoint_record: HumanInputV2FormDeliveryEndpoint,
) -> UploadFileAssociation:
capability = upload_capability_from_record(capability_record, endpoint_record)
if (
capability.id != UploadCapabilityId(record.upload_token_id)
or capability.endpoint_ref.form_ref.workspace_id != WorkspaceId(record.tenant_id)
or capability.endpoint_ref.form_ref.form_id != FormId(record.form_id)
or capability.endpoint_ref.endpoint_id != DeliveryEndpointId(record.endpoint_id)
or capability.app_id != AppId(record.app_id)
):
raise ValueError("uploaded file owner does not match its capability record")
return UploadFileAssociation(
id=UploadFileAssociationId(record.id),
capability_ref=capability.ref,
upload_file_id=record.upload_file_id,
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
@@ -0,0 +1,280 @@
"""SQLAlchemy Human Input v2 Form adapter.
The public operations own their transaction and eager-loading shape. Every
query carries the complete workspace/form/child owner predicates because domain
references are navigation values, not authorization tokens. ORM instances never
cross this boundary.
"""
from __future__ import annotations
import sqlalchemy as sa
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session, selectinload, sessionmaker
from core.human_input_v2.approval import (
DeliveryAttempt,
DeliveryEndpointRef,
FormCreation,
FormDefinitionProjection,
FormDeliveryProjection,
FormRef,
HumanInputForm,
UploadCapability,
UploadFileAssociation,
)
from core.human_input_v2.shared import WorkspaceId
from models.human_input_v2 import (
HumanInputV2Form,
HumanInputV2FormApproverGrant,
HumanInputV2FormDeliveryEndpoint,
HumanInputV2FormUploadToken,
)
from models.model import UploadFile
from .mappers import (
delivery_attempt_from_record,
delivery_attempt_to_record,
endpoint_from_record,
endpoint_to_record,
form_from_record,
form_to_record,
grant_from_record,
grant_to_record,
upload_capability_from_record,
upload_capability_to_record,
upload_file_from_record,
upload_file_to_record,
)
class FormPersistenceError(RuntimeError):
"""A Form Core persistence operation could not preserve its contract."""
class SQLAlchemyFormRepository:
"""Transactional, operation-oriented adapter for one form aggregate boundary."""
_session_maker: sessionmaker[Session]
def __init__(self, session_maker: sessionmaker[Session]) -> None:
self._session_maker = session_maker
def create_form(self, creation: FormCreation) -> HumanInputForm:
"""Persist the complete form/grant/endpoint snapshot in one transaction."""
try:
with self._session_maker() as session, session.begin():
session.add(form_to_record(creation.form))
session.add_all(grant_to_record(grant) for grant in creation.form.grants)
session.add_all(endpoint_to_record(endpoint) for endpoint in creation.endpoints)
session.flush()
return creation.form
except SQLAlchemyError as error:
raise FormPersistenceError("failed to create Human Input form snapshot") from error
def load_for_lifecycle(self, form_ref: FormRef) -> HumanInputForm | None:
"""Load one form and its grant membership in a fixed two-query shape."""
try:
with self._session_maker() as session, session.begin():
record = session.scalar(
select(HumanInputV2Form)
.options(selectinload(HumanInputV2Form.grants))
.where(
HumanInputV2Form.tenant_id == str(form_ref.workspace_id),
HumanInputV2Form.id == str(form_ref.form_id),
)
)
if record is None:
return None
return form_from_record(record, tuple(record.grants))
except SQLAlchemyError as error:
raise FormPersistenceError("failed to load Human Input form lifecycle state") from error
def load_delivery_projection(self, endpoint_ref: DeliveryEndpointRef) -> FormDeliveryProjection | None:
"""Load exactly one endpoint, grant, and form for delivery."""
try:
with self._session_maker() as session, session.begin():
row = session.execute(self._endpoint_graph_statement(endpoint_ref)).one_or_none()
if row is None:
return None
endpoint_record, grant_record, form_record = row
form = form_from_record(form_record, (grant_record,))
return FormDeliveryProjection(
form_ref=form.ref,
grant=grant_from_record(grant_record),
endpoint=endpoint_from_record(endpoint_record),
definition=form.definition,
rendered_content=form.rendered_content,
)
except SQLAlchemyError as error:
raise FormPersistenceError("failed to load Human Input delivery projection") from error
def load_definition_by_endpoint_token(
self,
*,
workspace_id: WorkspaceId,
token_hash: str,
) -> FormDefinitionProjection | None:
"""Resolve a token to a read model without returning grant authority."""
try:
with self._session_maker() as session, session.begin():
row = session.execute(
self._endpoint_graph_base().where(
HumanInputV2FormDeliveryEndpoint.tenant_id == str(workspace_id),
HumanInputV2FormDeliveryEndpoint.access_token_hash == token_hash,
)
).one_or_none()
if row is None:
return None
endpoint_record, grant_record, form_record = row
form = form_from_record(form_record, (grant_record,))
return FormDefinitionProjection(
form_ref=form.ref,
endpoint_ref=endpoint_from_record(endpoint_record).ref,
definition=form.definition,
rendered_content=form.rendered_content,
status=form.status,
node_timeout_at=form.node_timeout_at,
global_expires_at=form.global_expires_at,
)
except SQLAlchemyError as error:
raise FormPersistenceError("failed to load Human Input token projection") from error
def append_delivery_attempt(self, attempt: DeliveryAttempt) -> DeliveryAttempt:
"""Append one attempt after validating its full endpoint owner chain."""
try:
with self._session_maker() as session, session.begin():
endpoint_record = self._load_endpoint_record(session, attempt.endpoint_ref)
if endpoint_record is None:
raise ValueError("delivery attempt endpoint scope does not exist")
record = delivery_attempt_to_record(attempt)
session.add(record)
session.flush()
return delivery_attempt_from_record(record, endpoint_record)
except ValueError:
raise
except SQLAlchemyError as error:
raise FormPersistenceError("failed to append Human Input delivery attempt") from error
def create_upload_capability(self, capability: UploadCapability) -> UploadCapability:
"""Persist one capability only when endpoint and app ownership match."""
try:
with self._session_maker() as session, session.begin():
row = session.execute(self._endpoint_graph_statement(capability.endpoint_ref)).one_or_none()
if row is None:
raise ValueError("upload capability endpoint scope does not exist")
endpoint_record, _grant_record, form_record = row
if form_record.app_id != str(capability.app_id):
raise ValueError("upload capability app scope does not match the form")
record = upload_capability_to_record(capability)
session.add(record)
session.flush()
return upload_capability_from_record(record, endpoint_record)
except ValueError:
raise
except SQLAlchemyError as error:
raise FormPersistenceError("failed to create Human Input upload capability") from error
def associate_upload_file(self, association: UploadFileAssociation) -> UploadFileAssociation:
"""Persist a file only after resolving its token and UploadFile workspace owners."""
capability_ref = association.capability_ref
endpoint_ref = capability_ref.endpoint_ref
try:
with self._session_maker() as session, session.begin():
row = session.execute(
select(HumanInputV2FormUploadToken, HumanInputV2FormDeliveryEndpoint)
.join(
HumanInputV2FormDeliveryEndpoint,
sa.and_(
HumanInputV2FormDeliveryEndpoint.id == HumanInputV2FormUploadToken.endpoint_id,
HumanInputV2FormDeliveryEndpoint.tenant_id == HumanInputV2FormUploadToken.tenant_id,
HumanInputV2FormDeliveryEndpoint.form_id == HumanInputV2FormUploadToken.form_id,
),
)
.join(
HumanInputV2Form,
sa.and_(
HumanInputV2Form.id == HumanInputV2FormUploadToken.form_id,
HumanInputV2Form.tenant_id == HumanInputV2FormUploadToken.tenant_id,
HumanInputV2Form.app_id == HumanInputV2FormUploadToken.app_id,
),
)
.where(
HumanInputV2FormUploadToken.id == str(capability_ref.capability_id),
HumanInputV2FormUploadToken.tenant_id == str(endpoint_ref.form_ref.workspace_id),
HumanInputV2FormUploadToken.app_id == str(capability_ref.app_id),
HumanInputV2FormUploadToken.form_id == str(endpoint_ref.form_ref.form_id),
HumanInputV2FormUploadToken.endpoint_id == str(endpoint_ref.endpoint_id),
)
).one_or_none()
if row is None:
raise ValueError("upload file capability scope does not match")
capability_record, endpoint_record = row
upload_file_id = session.scalar(
select(UploadFile.id).where(
UploadFile.id == association.upload_file_id,
UploadFile.tenant_id == str(endpoint_ref.form_ref.workspace_id),
)
)
if upload_file_id is None:
raise ValueError("upload file workspace scope does not exist")
record = upload_file_to_record(association)
session.add(record)
session.flush()
return upload_file_from_record(record, capability_record, endpoint_record)
except ValueError:
raise
except SQLAlchemyError as error:
raise FormPersistenceError("failed to associate Human Input uploaded file") from error
@staticmethod
def _endpoint_graph_base() -> sa.Select[
tuple[HumanInputV2FormDeliveryEndpoint, HumanInputV2FormApproverGrant, HumanInputV2Form]
]:
return (
select(HumanInputV2FormDeliveryEndpoint, HumanInputV2FormApproverGrant, HumanInputV2Form)
.join(
HumanInputV2FormApproverGrant,
sa.and_(
HumanInputV2FormApproverGrant.id == HumanInputV2FormDeliveryEndpoint.approver_grant_id,
HumanInputV2FormApproverGrant.tenant_id == HumanInputV2FormDeliveryEndpoint.tenant_id,
HumanInputV2FormApproverGrant.form_id == HumanInputV2FormDeliveryEndpoint.form_id,
),
)
.join(
HumanInputV2Form,
sa.and_(
HumanInputV2Form.id == HumanInputV2FormDeliveryEndpoint.form_id,
HumanInputV2Form.tenant_id == HumanInputV2FormDeliveryEndpoint.tenant_id,
),
)
)
@classmethod
def _endpoint_graph_statement(
cls,
endpoint_ref: DeliveryEndpointRef,
) -> sa.Select[tuple[HumanInputV2FormDeliveryEndpoint, HumanInputV2FormApproverGrant, HumanInputV2Form]]:
return cls._endpoint_graph_base().where(
HumanInputV2FormDeliveryEndpoint.tenant_id == str(endpoint_ref.form_ref.workspace_id),
HumanInputV2FormDeliveryEndpoint.form_id == str(endpoint_ref.form_ref.form_id),
HumanInputV2FormDeliveryEndpoint.approver_grant_id == str(endpoint_ref.grant_ref.grant_id),
HumanInputV2FormDeliveryEndpoint.id == str(endpoint_ref.endpoint_id),
)
@classmethod
def _load_endpoint_record(
cls,
session: Session,
endpoint_ref: DeliveryEndpointRef,
) -> HumanInputV2FormDeliveryEndpoint | None:
row = session.execute(cls._endpoint_graph_statement(endpoint_ref)).one_or_none()
return row[0] if row is not None else None
@@ -0,0 +1,5 @@
"""SQLAlchemy persistence adapter for the IM Control Plane domain."""
from .repository import SQLAlchemyIMControlPlaneRepository
__all__ = ["SQLAlchemyIMControlPlaneRepository"]
@@ -0,0 +1,338 @@
"""Explicit bidirectional mappings for IM domain values and ORM records."""
from datetime import UTC, datetime
from pydantic import TypeAdapter
from core.human_input_v2.entities import IMBindingScope, IMSyncRemovalReason
from core.human_input_v2.im_integration import (
EncryptedCredentials,
IMBinding,
IMIdentity,
IMIntegration,
IMSyncRun,
IntegrationRevisionToken,
OpaqueProviderPayload,
ProviderTenantIdentity,
SyncContactSnapshot,
SyncIdentitySnapshot,
SyncResultFact,
)
from core.human_input_v2.shared import (
AccountId,
ContactId,
IMBindingId,
IMIdentityId,
IMSyncResultId,
IMSyncRunId,
IntegrationId,
NormalizedEmail,
UtcTimestamp,
WorkspaceId,
)
from models.human_input_v2 import (
HumanInputIMBinding,
HumanInputIMIdentity,
HumanInputIMIntegration,
HumanInputIMSyncResult,
HumanInputIMSyncRun,
IMIdentityRawPayload,
IMIntegrationEncryptedCredentials,
IMSyncContactSnapshot,
IMSyncDirectoryEntryPayload,
IMSyncIdentitySnapshot,
)
_CREDENTIAL_ADAPTER: TypeAdapter[IMIntegrationEncryptedCredentials] = TypeAdapter(IMIntegrationEncryptedCredentials)
def _timestamp(value: datetime) -> UtcTimestamp:
"""Interpret database-naive timestamps as UTC, matching Dify persistence."""
return UtcTimestamp(value.replace(tzinfo=UTC) if value.tzinfo is None else value)
def integration_from_record(record: HumanInputIMIntegration) -> IMIntegration:
"""Map one Integration record into its CAS aggregate."""
if record.provider_tenant_id is None:
raise ValueError("integration record is missing provider_tenant_id")
credential_values = record.encrypted_credentials.model_dump(mode="json", exclude_none=True)
credential_values.pop("provider", None)
return IMIntegration(
id=IntegrationId(record.id),
workspace_id=WorkspaceId(record.tenant_id) if record.tenant_id is not None else None,
provider_tenant=ProviderTenantIdentity(record.provider, record.provider_tenant_id),
encrypted_credentials=EncryptedCredentials.from_mapping(credential_values),
configured_by_account_id=(
AccountId(record.configured_by_account_id) if record.configured_by_account_id is not None else None
),
callback_url=record.callback_url,
config_version=record.config_version,
status=record.status,
safe_status_reason=record.safe_status_reason,
last_checked_at=_timestamp(record.last_checked_at) if record.last_checked_at is not None else None,
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def integration_to_record(integration: IMIntegration) -> HumanInputIMIntegration:
"""Map one Integration aggregate into a detached persistence record."""
credential_values = integration.encrypted_credentials.to_mapping()
credential_values["provider"] = integration.provider_tenant.provider.value
credentials = _CREDENTIAL_ADAPTER.validate_python(credential_values)
record = HumanInputIMIntegration(
provider=integration.provider_tenant.provider,
encrypted_credentials=credentials,
tenant_id=str(integration.workspace_id) if integration.workspace_id is not None else None,
provider_tenant_id=integration.provider_tenant.provider_tenant_id,
status=integration.status,
config_version=integration.config_version,
configured_by_account_id=(
str(integration.configured_by_account_id) if integration.configured_by_account_id is not None else None
),
callback_url=integration.callback_url,
safe_status_reason=integration.safe_status_reason,
last_checked_at=integration.last_checked_at.value if integration.last_checked_at is not None else None,
)
record.id = str(integration.id)
record.created_at = integration.created_at.value
record.updated_at = integration.updated_at.value
return record
def identity_from_record(record: HumanInputIMIdentity) -> IMIdentity:
"""Map one current provider identity record into a domain value."""
return IMIdentity(
id=IMIdentityId(record.id),
integration_id=IntegrationId(record.integration_id),
provider=record.provider,
provider_user_id=record.provider_user_id,
display_name=record.display_name,
normalized_name=record.normalized_name,
email=record.email,
normalized_email=NormalizedEmail(record.normalized_email) if record.normalized_email is not None else None,
raw_payload=OpaqueProviderPayload.from_mapping(record.raw_payload.root),
last_seen_sync_run_id=(
IMSyncRunId(record.last_seen_sync_run_id) if record.last_seen_sync_run_id is not None else None
),
last_seen_at=_timestamp(record.last_seen_at) if record.last_seen_at is not None else None,
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def identity_to_record(identity: IMIdentity) -> HumanInputIMIdentity:
"""Map one current provider identity into a detached record."""
record = HumanInputIMIdentity(
integration_id=str(identity.integration_id),
provider=identity.provider,
provider_user_id=identity.provider_user_id,
display_name=identity.display_name,
normalized_name=identity.normalized_name,
email=identity.email,
normalized_email=str(identity.normalized_email) if identity.normalized_email is not None else None,
raw_payload=IMIdentityRawPayload(identity.raw_payload.to_mapping()),
last_seen_sync_run_id=(
str(identity.last_seen_sync_run_id) if identity.last_seen_sync_run_id is not None else None
),
last_seen_at=identity.last_seen_at.value if identity.last_seen_at is not None else None,
)
record.id = str(identity.id)
record.created_at = identity.created_at.value
record.updated_at = identity.updated_at.value
return record
def binding_from_record(record: HumanInputIMBinding) -> IMBinding:
"""Map one current binding record into a domain value."""
return IMBinding(
id=IMBindingId(record.id),
integration_id=IntegrationId(record.integration_id),
scope=IMBindingScope(record.scope),
scope_id=record.scope_id,
contact_id=ContactId(record.contact_id),
identity_id=IMIdentityId(record.im_identity_id),
provider=record.provider,
bound_by_account_id=AccountId(record.bound_by_account_id) if record.bound_by_account_id is not None else None,
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def binding_to_record(binding: IMBinding) -> HumanInputIMBinding:
"""Map one current binding into a detached record."""
record = HumanInputIMBinding(
integration_id=str(binding.integration_id),
scope=binding.scope,
scope_id=binding.scope_id,
contact_id=str(binding.contact_id),
im_identity_id=str(binding.identity_id),
provider=binding.provider,
bound_by_account_id=str(binding.bound_by_account_id) if binding.bound_by_account_id is not None else None,
)
record.id = str(binding.id)
record.created_at = binding.created_at.value
record.updated_at = binding.updated_at.value
return record
def sync_run_from_record(record: HumanInputIMSyncRun) -> IMSyncRun:
"""Map one sync run record into its independent aggregate."""
return IMSyncRun(
id=IMSyncRunId(record.id),
integration_revision=IntegrationRevisionToken(
IntegrationId(record.integration_id), record.integration_config_version
),
provider=record.provider,
status=record.status,
added_count=record.added_count,
not_matched_count=record.not_matched_count,
failed_count=record.failed_count,
removed_count=record.removed_count,
skipped_count=record.skipped_count,
started_by_account_id=(
AccountId(record.started_by_account_id) if record.started_by_account_id is not None else None
),
started_at=_timestamp(record.started_at) if record.started_at is not None else None,
finished_at=_timestamp(record.finished_at) if record.finished_at is not None else None,
error_code=record.error_code,
error_message=record.error_message,
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def sync_run_to_record(run: IMSyncRun) -> HumanInputIMSyncRun:
"""Map one sync aggregate into a detached record."""
record = HumanInputIMSyncRun(
integration_id=str(run.integration_revision.integration_id),
integration_config_version=run.integration_revision.config_version,
provider=run.provider,
status=run.status,
added_count=run.added_count,
not_matched_count=run.not_matched_count,
failed_count=run.failed_count,
removed_count=run.removed_count,
skipped_count=run.skipped_count,
started_by_account_id=str(run.started_by_account_id) if run.started_by_account_id is not None else None,
started_at=run.started_at.value if run.started_at is not None else None,
finished_at=run.finished_at.value if run.finished_at is not None else None,
error_code=run.error_code,
error_message=run.error_message,
)
record.id = str(run.id)
record.created_at = run.created_at.value
record.updated_at = run.updated_at.value
return record
def sync_result_from_record(record: HumanInputIMSyncResult) -> SyncResultFact:
"""Map one append-only result record into an immutable domain fact."""
contact_snapshot = record.contact_snapshot
identity_snapshot = record.identity_snapshot
return SyncResultFact(
id=IMSyncResultId(record.id),
integration_id=IntegrationId(record.integration_id),
sync_run_id=IMSyncRunId(record.sync_run_id),
result_type=record.result_type,
provider_user_id=record.provider_user_id,
display_name=record.display_name,
email=record.email,
normalized_email=NormalizedEmail(record.normalized_email) if record.normalized_email is not None else None,
contact_id=ContactId(record.contact_id) if record.contact_id is not None else None,
identity_id=IMIdentityId(record.im_identity_id) if record.im_identity_id is not None else None,
binding_id=IMBindingId(record.im_binding_id) if record.im_binding_id is not None else None,
removal_reason=IMSyncRemovalReason(record.removal_reason) if record.removal_reason is not None else None,
reason_code=record.reason_code,
reason_message=record.reason_message,
directory_entry_payload=(
OpaqueProviderPayload.from_mapping(record.directory_entry_payload.root)
if record.directory_entry_payload is not None
else None
),
contact_snapshot=(
SyncContactSnapshot(
contact_id=ContactId(contact_snapshot.contact_id),
name=contact_snapshot.name,
email=contact_snapshot.email,
avatar_file_id=contact_snapshot.avatar_file_id,
)
if contact_snapshot is not None
else None
),
identity_snapshot=(
SyncIdentitySnapshot(
identity_id=IMIdentityId(identity_snapshot.identity_id),
provider=identity_snapshot.provider,
provider_user_id=identity_snapshot.provider_user_id,
display_name=identity_snapshot.display_name,
email=identity_snapshot.email,
)
if identity_snapshot is not None
else None
),
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def sync_result_to_record(result: SyncResultFact) -> HumanInputIMSyncResult:
"""Map one immutable sync result fact into a detached record."""
contact_snapshot = result.contact_snapshot
identity_snapshot = result.identity_snapshot
record = HumanInputIMSyncResult(
integration_id=str(result.integration_id),
sync_run_id=str(result.sync_run_id),
result_type=result.result_type,
provider_user_id=result.provider_user_id,
display_name=result.display_name,
email=result.email,
normalized_email=str(result.normalized_email) if result.normalized_email is not None else None,
contact_id=str(result.contact_id) if result.contact_id is not None else None,
im_identity_id=str(result.identity_id) if result.identity_id is not None else None,
im_binding_id=str(result.binding_id) if result.binding_id is not None else None,
removal_reason=result.removal_reason,
reason_code=result.reason_code,
reason_message=result.reason_message,
directory_entry_payload=(
IMSyncDirectoryEntryPayload(result.directory_entry_payload.to_mapping())
if result.directory_entry_payload is not None
else None
),
contact_snapshot=(
IMSyncContactSnapshot(
contact_id=str(contact_snapshot.contact_id),
name=contact_snapshot.name,
email=contact_snapshot.email,
avatar_file_id=contact_snapshot.avatar_file_id,
)
if contact_snapshot is not None
else None
),
identity_snapshot=(
IMSyncIdentitySnapshot(
identity_id=str(identity_snapshot.identity_id),
provider=identity_snapshot.provider,
provider_user_id=identity_snapshot.provider_user_id,
display_name=identity_snapshot.display_name,
email=identity_snapshot.email,
)
if identity_snapshot is not None
else None
),
)
record.id = str(result.id)
record.created_at = result.created_at.value
record.updated_at = result.updated_at.value
return record
@@ -0,0 +1,689 @@
"""SQLAlchemy IM Control Plane adapter.
Configuration transitions, active-run creation, and reconciliation apply each
own their complete transaction. Integration locks serialize single-active-run
decisions. Deployment-wide creation additionally locks the stable ``DifySetup``
owner because nullable uniqueness cannot enforce the EE singleton. ORM records
never cross this boundary, and aggregate relationships are loaded explicitly
because their model relationships use ``lazy="raise"``.
"""
from __future__ import annotations
import sqlalchemy as sa
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload, sessionmaker
from core.human_input_v2.contact_directory import ContactSnapshot
from core.human_input_v2.entities import (
IMBindingScope,
IMProvider,
IMSyncRemovalReason,
IMSyncResultType,
IMSyncRunStatus,
)
from core.human_input_v2.im_integration import (
ActiveRunDecision,
ActiveRunDecisionKind,
ApplyReconciliationResult,
ApplyReconciliationStatus,
BindingResolutionKind,
BindingResolutionResult,
ConfigurationTransition,
ConfigurationTransitionKind,
EffectiveBindingResolver,
IMBinding,
IMIdentity,
IMIntegration,
IMIntegrationState,
IMSyncRun,
IntegrationDeletion,
IntegrationRevisionToken,
MatchKind,
ReconciliationAction,
ReconciliationPlan,
ReconciliationSnapshot,
StaleRevision,
SyncContactSnapshot,
SyncIdentitySnapshot,
SyncResultFact,
)
from core.human_input_v2.shared import (
AccountId,
ContactId,
IMBindingId,
IMIdentityId,
IMSyncResultId,
IMSyncRunId,
IntegrationId,
NormalizedEmail,
UtcTimestamp,
WorkspaceId,
)
from libs.uuid_utils import uuidv7
from models.account import Account, AccountStatus
from models.human_input_v2 import (
HumanInputContact,
HumanInputIMBinding,
HumanInputIMIdentity,
HumanInputIMIntegration,
HumanInputIMSyncResult,
HumanInputIMSyncRun,
)
from models.model import DifySetup
from repositories.human_input_v2.contact_directory.mappers import contact_from_record
from .mappers import (
binding_from_record,
binding_to_record,
identity_from_record,
identity_to_record,
integration_from_record,
integration_to_record,
sync_result_from_record,
sync_result_to_record,
sync_run_from_record,
sync_run_to_record,
)
class SQLAlchemyIMControlPlaneRepository:
"""Transactional adapter for configuration, sync, and binding invariants."""
_session_maker: sessionmaker[Session]
def __init__(self, session_maker: sessionmaker[Session]) -> None:
self._session_maker = session_maker
def create_integration(self, integration: IMIntegration) -> IMIntegration:
"""Create the first configuration after serializing its owner scope."""
with self._session_maker() as session, session.begin():
if integration.workspace_id is None:
self._lock_deployment_owner(session)
existing_deployment_integration_id = session.scalar(
select(HumanInputIMIntegration.id).where(HumanInputIMIntegration.tenant_id.is_(None)).limit(1)
)
if existing_deployment_integration_id is not None:
raise ValueError("deployment-wide IM integration already exists")
record = integration_to_record(integration)
session.add(record)
session.flush()
return integration_from_record(record)
def compare_and_swap_configuration(self, transition: ConfigurationTransition) -> IMIntegration | StaleRevision:
"""Apply a complete-token rotation or replacement in one transaction."""
with self._session_maker() as session, session.begin():
current = session.scalar(self._locked_integration_statement(transition.expected_revision))
if current is None:
return StaleRevision(
transition.expected_revision,
self._current_revision(session, transition.expected_revision.integration_id),
)
if transition.kind is ConfigurationTransitionKind.CREDENTIAL_ROTATION:
self._copy_integration_values(current, transition.integration)
session.flush()
return integration_from_record(current)
session.execute(
sa.delete(HumanInputIMBinding).where(
HumanInputIMBinding.integration_id == str(transition.expected_revision.integration_id)
)
)
session.execute(
sa.delete(HumanInputIMIdentity).where(
HumanInputIMIdentity.integration_id == str(transition.expected_revision.integration_id)
)
)
session.delete(current)
session.flush()
replacement = integration_to_record(transition.integration)
session.add(replacement)
session.flush()
return integration_from_record(replacement)
def compare_and_swap_delete(self, deletion: IntegrationDeletion | StaleRevision) -> None | StaleRevision:
"""Delete current configuration and current children under complete CAS."""
if isinstance(deletion, StaleRevision):
return deletion
with self._session_maker() as session, session.begin():
current = session.scalar(self._locked_integration_statement(deletion.expected_revision))
if current is None:
return StaleRevision(
deletion.expected_revision,
self._current_revision(session, deletion.expected_revision.integration_id),
)
integration_id = str(deletion.expected_revision.integration_id)
session.execute(sa.delete(HumanInputIMBinding).where(HumanInputIMBinding.integration_id == integration_id))
session.execute(
sa.delete(HumanInputIMIdentity).where(HumanInputIMIdentity.integration_id == integration_id)
)
session.delete(current)
return None
def create_or_get_active_run(
self,
integration_revision: IntegrationRevisionToken,
*,
sync_run_id: IMSyncRunId,
started_by_account_id: AccountId | None,
now: UtcTimestamp,
) -> ActiveRunDecision:
"""Serialize trigger decisions by locking the owning Integration row."""
with self._session_maker() as session, session.begin():
integration = session.scalar(self._locked_integration_statement(integration_revision))
if integration is None:
return ActiveRunDecision(
kind=ActiveRunDecisionKind.STALE_REVISION,
run=None,
stale_revision=StaleRevision(
integration_revision,
self._current_revision(session, integration_revision.integration_id),
),
)
existing = session.scalar(
select(HumanInputIMSyncRun)
.where(
HumanInputIMSyncRun.integration_id == str(integration_revision.integration_id),
HumanInputIMSyncRun.status.in_((IMSyncRunStatus.QUEUED, IMSyncRunStatus.RUNNING)),
)
.order_by(HumanInputIMSyncRun.created_at, HumanInputIMSyncRun.id)
.limit(1)
)
if existing is not None:
return ActiveRunDecision(ActiveRunDecisionKind.EXISTING_ACTIVE, sync_run_from_record(existing))
run = IMSyncRun.create(
sync_run_id=sync_run_id,
integration_revision=integration_revision,
provider=integration.provider,
started_by_account_id=started_by_account_id,
now=now,
)
record = sync_run_to_record(run)
session.add(record)
session.flush()
return ActiveRunDecision(ActiveRunDecisionKind.CREATED, sync_run_from_record(record))
def load_reconciliation_snapshot(self, sync_run_id: IMSyncRunId) -> ReconciliationSnapshot:
"""Load current identities, bindings, and owner-scoped Contact facts."""
with self._session_maker() as session, session.begin():
run = session.get_one(HumanInputIMSyncRun, str(sync_run_id))
integration = session.get_one(HumanInputIMIntegration, run.integration_id)
identity_records = session.scalars(
select(HumanInputIMIdentity).where(
HumanInputIMIdentity.integration_id == run.integration_id,
HumanInputIMIdentity.provider == run.provider,
)
).all()
binding_records = session.scalars(
select(HumanInputIMBinding).where(HumanInputIMBinding.integration_id == run.integration_id)
).all()
contact_statement = select(HumanInputContact)
if integration.tenant_id is not None:
contact_statement = contact_statement.where(
sa.or_(
HumanInputContact.tenant_id == integration.tenant_id,
HumanInputContact.tenant_id.is_(None),
)
)
contact_records = session.scalars(contact_statement).all()
account_ids = {record.account_id for record in contact_records if record.account_id is not None}
unavailable_account_ids = set(
session.scalars(
select(Account.id).where(
Account.id.in_(account_ids),
Account.status != AccountStatus.ACTIVE,
)
).all()
if account_ids
else ()
)
contacts = tuple(contact_from_record(record) for record in contact_records)
return ReconciliationSnapshot(
identities=tuple(identity_from_record(record) for record in identity_records),
bindings=tuple(binding_from_record(record) for record in binding_records),
contacts=tuple(
ContactSnapshot(
contact,
contact.account_id is None or str(contact.account_id) not in unavailable_account_ids,
)
for contact in contacts
),
)
def apply_reconciliation(self, plan: ReconciliationPlan, *, now: UtcTimestamp) -> ApplyReconciliationResult:
"""Idempotently apply one plan using the persisted run capture as authority."""
with self._session_maker() as session, session.begin():
run_record = session.scalar(
select(HumanInputIMSyncRun).where(HumanInputIMSyncRun.id == str(plan.sync_run_id)).with_for_update()
)
if run_record is None:
raise ValueError("sync run not found")
captured_revision = IntegrationRevisionToken(
IntegrationId(run_record.integration_id),
run_record.integration_config_version,
)
if plan.integration_revision != captured_revision:
raise ValueError("sync run revision does not match plan")
if plan.provider is not run_record.provider:
raise ValueError("sync run provider does not match plan")
existing_results = self._load_result_records(session, plan.sync_run_id)
if run_record.status in (IMSyncRunStatus.SUCCEEDED, IMSyncRunStatus.FAILED):
return ApplyReconciliationResult(
ApplyReconciliationStatus.ALREADY_APPLIED,
sync_run_from_record(run_record),
tuple(sync_result_from_record(record) for record in existing_results),
)
integration_record = session.scalar(self._locked_integration_statement(captured_revision))
if integration_record is None or integration_record.provider is not run_record.provider:
stale_result = self._stale_result(plan, now)
self._append_result_record(session, stale_result)
run_record.status = IMSyncRunStatus.FAILED
run_record.failed_count = 1
run_record.started_at = run_record.started_at or now.value
run_record.finished_at = now.value
run_record.error_code = "stale_integration_revision"
run_record.error_message = "Integration configuration changed before reconciliation apply."
run_record.updated_at = now.value
session.flush()
return ApplyReconciliationResult(
ApplyReconciliationStatus.STALE_REVISION,
sync_run_from_record(run_record),
(stale_result,),
)
results: list[SyncResultFact] = []
for action in plan.actions:
result = self._apply_action(session, plan, action, now)
self._append_result_record(session, result)
results.append(result)
for identity_id in plan.removed_identity_ids:
removal_results = self._remove_identity(session, plan, identity_id, now)
for removal_result in removal_results:
self._append_result_record(session, removal_result)
results.append(removal_result)
run_record.status = IMSyncRunStatus.SUCCEEDED
run_record.added_count = sum(result.result_type is IMSyncResultType.ADDED for result in results)
run_record.not_matched_count = sum(result.result_type is IMSyncResultType.NOT_MATCHED for result in results)
run_record.failed_count = sum(result.result_type is IMSyncResultType.FAILED for result in results)
run_record.removed_count = sum(result.result_type is IMSyncResultType.REMOVED for result in results)
run_record.skipped_count = sum(result.result_type is IMSyncResultType.SKIPPED for result in results)
run_record.started_at = run_record.started_at or now.value
run_record.finished_at = now.value
run_record.updated_at = now.value
session.flush()
return ApplyReconciliationResult(
ApplyReconciliationStatus.APPLIED,
sync_run_from_record(run_record),
tuple(results),
)
def load_integration_state(self, integration_id: IntegrationId) -> IMIntegrationState:
"""Eagerly load and map an Integration with all modeled child relationships."""
with self._session_maker() as session, session.begin():
record = session.scalar(
select(HumanInputIMIntegration)
.where(HumanInputIMIntegration.id == str(integration_id))
.options(
selectinload(HumanInputIMIntegration.identities).selectinload(HumanInputIMIdentity.bindings),
selectinload(HumanInputIMIntegration.sync_runs).selectinload(HumanInputIMSyncRun.results),
)
)
if record is None:
raise ValueError("integration not found")
identity_records = tuple(record.identities)
run_records = tuple(record.sync_runs)
return IMIntegrationState(
integration=integration_from_record(record),
identities=tuple(identity_from_record(item) for item in identity_records),
bindings=tuple(
binding_from_record(binding) for identity in identity_records for binding in identity.bindings
),
sync_runs=tuple(sync_run_from_record(item) for item in run_records),
sync_results=tuple(sync_result_from_record(result) for run in run_records for result in run.results),
)
def resolve_effective_binding(
self,
*,
integration_id: IntegrationId,
provider: IMProvider,
workspace_id: WorkspaceId,
contact_id: ContactId,
) -> BindingResolutionResult:
"""Validate Integration ownership before loading consumer-safe binding facts."""
with self._session_maker() as session, session.begin():
integration_record = session.scalar(
select(HumanInputIMIntegration).where(
HumanInputIMIntegration.id == str(integration_id),
sa.or_(
HumanInputIMIntegration.tenant_id == str(workspace_id),
HumanInputIMIntegration.tenant_id.is_(None),
),
)
)
if integration_record is None or integration_record.provider is not provider:
return BindingResolutionResult(BindingResolutionKind.INVALID_BINDING, None)
integration = integration_from_record(integration_record)
contact_record = session.scalar(
select(HumanInputContact).where(
HumanInputContact.id == str(contact_id),
sa.or_(HumanInputContact.tenant_id == str(workspace_id), HumanInputContact.tenant_id.is_(None)),
)
)
if contact_record is None:
return BindingResolutionResult(BindingResolutionKind.NOT_AVAILABLE, None)
contact = ContactSnapshot(contact_from_record(contact_record), True)
identities = tuple(
identity_from_record(record)
for record in session.scalars(
select(HumanInputIMIdentity).where(
HumanInputIMIdentity.integration_id == str(integration_id),
HumanInputIMIdentity.provider == provider,
)
).all()
)
bindings = tuple(
binding_from_record(record)
for record in session.scalars(
select(HumanInputIMBinding).where(
HumanInputIMBinding.integration_id == str(integration_id),
HumanInputIMBinding.provider == provider,
)
).all()
)
return EffectiveBindingResolver.resolve(
integration_revision=integration.revision,
provider_tenant=integration.provider_tenant,
workspace_id=workspace_id,
contact=contact,
identities=identities,
bindings=bindings,
)
def append_sync_results(self, results: tuple[SyncResultFact, ...]) -> None:
"""Append diagnostic facts in their own explicit transaction."""
with self._session_maker() as session, session.begin():
for result in results:
self._append_result_record(session, result)
@staticmethod
def _locked_integration_statement(
revision: IntegrationRevisionToken,
) -> sa.Select[tuple[HumanInputIMIntegration]]:
"""Build the complete CAS predicate and row lock used by write paths."""
return (
select(HumanInputIMIntegration)
.where(
HumanInputIMIntegration.id == str(revision.integration_id),
HumanInputIMIntegration.config_version == revision.config_version,
)
.with_for_update()
)
@staticmethod
def _current_revision(session: Session, integration_id: IntegrationId) -> IntegrationRevisionToken | None:
record = session.get(HumanInputIMIntegration, str(integration_id))
if record is None:
return None
return IntegrationRevisionToken(IntegrationId(record.id), record.config_version)
@staticmethod
def _lock_deployment_owner(session: Session) -> None:
"""Serialize EE singleton decisions on the deployment's stable owner."""
setup_version = session.scalars(select(DifySetup.version).with_for_update()).one_or_none()
if setup_version is None:
raise ValueError("deployment setup row is required for deployment-wide IM integration")
@staticmethod
def _copy_integration_values(record: HumanInputIMIntegration, integration: IMIntegration) -> None:
mapped = integration_to_record(integration)
record.provider = mapped.provider
record.encrypted_credentials = mapped.encrypted_credentials
record.tenant_id = mapped.tenant_id
record.provider_tenant_id = mapped.provider_tenant_id
record.status = mapped.status
record.config_version = mapped.config_version
record.configured_by_account_id = mapped.configured_by_account_id
record.callback_url = mapped.callback_url
record.safe_status_reason = mapped.safe_status_reason
record.last_checked_at = mapped.last_checked_at
record.updated_at = mapped.updated_at
@staticmethod
def _load_result_records(session: Session, sync_run_id: IMSyncRunId) -> list[HumanInputIMSyncResult]:
return list(
session.scalars(
select(HumanInputIMSyncResult)
.where(HumanInputIMSyncResult.sync_run_id == str(sync_run_id))
.order_by(HumanInputIMSyncResult.created_at, HumanInputIMSyncResult.id)
).all()
)
def _apply_action(
self,
session: Session,
plan: ReconciliationPlan,
action: ReconciliationAction,
now: UtcTimestamp,
) -> SyncResultFact:
identity_record: HumanInputIMIdentity | None = None
binding_record: HumanInputIMBinding | None = None
contact_record: HumanInputContact | None = None
if action.identity_id is not None:
identity_record = session.scalar(
select(HumanInputIMIdentity).where(
HumanInputIMIdentity.id == str(action.identity_id),
HumanInputIMIdentity.integration_id == str(plan.integration_revision.integration_id),
)
)
if identity_record is None:
raise ValueError("matched identity no longer exists")
self._copy_entry_to_identity(identity_record, action, plan, now)
if action.binding_id is not None:
binding_record = session.get(HumanInputIMBinding, str(action.binding_id))
elif action.match_kind is MatchKind.NORMALIZED_EMAIL and action.contact_id is not None:
identity = IMIdentity.create(
identity_id=IMIdentityId(str(uuidv7())),
integration_id=plan.integration_revision.integration_id,
provider=plan.provider,
provider_user_id=action.entry.provider_user_id,
display_name=action.entry.display_name,
email=action.entry.email,
raw_payload=action.entry.raw_payload.to_mapping(),
last_seen_sync_run_id=plan.sync_run_id,
last_seen_at=now,
now=now,
)
identity_record = identity_to_record(identity)
session.add(identity_record)
binding = IMBinding.create(
binding_id=IMBindingId(str(uuidv7())),
integration_id=plan.integration_revision.integration_id,
scope=IMBindingScope.ORGANIZATION,
scope_id=str(plan.integration_revision.integration_id),
contact_id=action.contact_id,
identity_id=identity.id,
provider=plan.provider,
bound_by_account_id=None,
now=now,
)
binding_record = binding_to_record(binding)
session.add(binding_record)
if action.contact_id is not None:
contact_record = session.get(HumanInputContact, str(action.contact_id))
result_type = (
IMSyncResultType.NOT_MATCHED
if action.match_kind is MatchKind.UNMATCHED
else IMSyncResultType.ADDED
if action.match_kind is MatchKind.NORMALIZED_EMAIL
else IMSyncResultType.SKIPPED
)
return SyncResultFact(
id=IMSyncResultId(str(uuidv7())),
integration_id=plan.integration_revision.integration_id,
sync_run_id=plan.sync_run_id,
result_type=result_type,
provider_user_id=action.entry.provider_user_id,
display_name=action.entry.display_name,
email=action.entry.email,
normalized_email=action.entry.normalized_email,
contact_id=action.contact_id,
identity_id=IMIdentityId(identity_record.id) if identity_record is not None else None,
binding_id=IMBindingId(binding_record.id) if binding_record is not None else None,
removal_reason=None,
reason_code="existing_provider_identity" if result_type is IMSyncResultType.SKIPPED else None,
reason_message=None,
directory_entry_payload=action.entry.raw_payload,
contact_snapshot=(
SyncContactSnapshot(
contact_id=ContactId(contact_record.id),
name=contact_record.name,
email=contact_record.email,
avatar_file_id=contact_record.avatar_file_id,
)
if contact_record is not None
else None
),
identity_snapshot=None,
created_at=now,
updated_at=now,
)
@staticmethod
def _copy_entry_to_identity(
record: HumanInputIMIdentity,
action: ReconciliationAction,
plan: ReconciliationPlan,
now: UtcTimestamp,
) -> None:
record.provider_user_id = action.entry.provider_user_id
record.display_name = action.entry.display_name
record.normalized_name = action.entry.display_name.casefold() if action.entry.display_name else None
record.email = action.entry.email
record.normalized_email = str(action.entry.normalized_email) if action.entry.normalized_email else None
from models.human_input_v2 import IMIdentityRawPayload
record.raw_payload = IMIdentityRawPayload(action.entry.raw_payload.to_mapping())
record.last_seen_sync_run_id = str(plan.sync_run_id)
record.last_seen_at = now.value
record.updated_at = now.value
def _remove_identity(
self,
session: Session,
plan: ReconciliationPlan,
identity_id: IMIdentityId,
now: UtcTimestamp,
) -> tuple[SyncResultFact, ...]:
record = session.scalar(
select(HumanInputIMIdentity).where(
HumanInputIMIdentity.id == str(identity_id),
HumanInputIMIdentity.integration_id == str(plan.integration_revision.integration_id),
)
)
if record is None:
return ()
binding_records = tuple(
session.scalars(
select(HumanInputIMBinding)
.where(
HumanInputIMBinding.integration_id == str(plan.integration_revision.integration_id),
HumanInputIMBinding.im_identity_id == record.id,
)
.order_by(HumanInputIMBinding.created_at, HumanInputIMBinding.id)
).all()
)
bindings_for_results: tuple[HumanInputIMBinding | None, ...] = binding_records or (None,)
results: list[SyncResultFact] = []
for binding in bindings_for_results:
contact_record = session.get(HumanInputContact, binding.contact_id) if binding is not None else None
results.append(
SyncResultFact(
id=IMSyncResultId(str(uuidv7())),
integration_id=plan.integration_revision.integration_id,
sync_run_id=plan.sync_run_id,
result_type=IMSyncResultType.REMOVED,
provider_user_id=record.provider_user_id,
display_name=record.display_name,
email=record.email,
normalized_email=(
NormalizedEmail(record.normalized_email) if record.normalized_email is not None else None
),
contact_id=ContactId(binding.contact_id) if binding is not None else None,
identity_id=identity_id,
binding_id=IMBindingId(binding.id) if binding is not None else None,
removal_reason=IMSyncRemovalReason.NOT_PRESENT_IN_DIRECTORY,
reason_code="not_present_in_directory",
reason_message=None,
directory_entry_payload=None,
contact_snapshot=(
SyncContactSnapshot(
contact_id=ContactId(contact_record.id),
name=contact_record.name,
email=contact_record.email,
avatar_file_id=contact_record.avatar_file_id,
)
if contact_record is not None
else None
),
identity_snapshot=SyncIdentitySnapshot(
identity_id=identity_id,
provider=record.provider,
provider_user_id=record.provider_user_id,
display_name=record.display_name,
email=record.email,
),
created_at=now,
updated_at=now,
)
)
for binding in binding_records:
session.delete(binding)
session.delete(record)
return tuple(results)
@staticmethod
def _stale_result(plan: ReconciliationPlan, now: UtcTimestamp) -> SyncResultFact:
return SyncResultFact(
id=IMSyncResultId(str(uuidv7())),
integration_id=plan.integration_revision.integration_id,
sync_run_id=plan.sync_run_id,
result_type=IMSyncResultType.FAILED,
provider_user_id=None,
display_name=None,
email=None,
normalized_email=None,
contact_id=None,
identity_id=None,
binding_id=None,
removal_reason=None,
reason_code="stale_integration_revision",
reason_message="Integration configuration changed before reconciliation apply.",
directory_entry_payload=None,
contact_snapshot=None,
identity_snapshot=None,
created_at=now,
updated_at=now,
)
@staticmethod
def _append_result_record(session: Session, result: SyncResultFact) -> None:
session.add(sync_result_to_record(result))
@@ -0,0 +1,31 @@
"""SQLAlchemy persistence for Human Input v2 submission and shared audit facts."""
from .mappers import (
audit_event_from_record,
audit_event_to_record,
proof_from_record_value,
proof_to_record_value,
submission_from_record,
submission_to_record,
)
from .otp_audit_writer import SQLAlchemyOTPChallengeAuditWriter
from .repository import (
SQLAlchemySubmissionRepository,
SQLAlchemySubmissionTransaction,
SubmissionPersistenceError,
SubmissionScopeNotFoundError,
)
__all__ = [
"SQLAlchemyOTPChallengeAuditWriter",
"SQLAlchemySubmissionRepository",
"SQLAlchemySubmissionTransaction",
"SubmissionPersistenceError",
"SubmissionScopeNotFoundError",
"audit_event_from_record",
"audit_event_to_record",
"proof_from_record_value",
"proof_to_record_value",
"submission_from_record",
"submission_to_record",
]
@@ -0,0 +1,312 @@
"""Explicit mappings for verified proof, actor, submission, and audit records."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import assert_never
from core.human_input_v2.approval import (
AccountSubmissionActor,
ContactOTPSubject,
EmailAddressOTPSubject,
EmailAddressSubmissionActor,
EmailOTPSubject,
EndUserSubmissionActor,
FormAuthorizationAuditEvent,
FormAuthorizationAuditEventType,
FormRef,
FormSubmission,
FrozenJSONObject,
SubmissionActor,
VerifiedAccountSessionProof,
VerifiedEmailOTPProof,
VerifiedIMIdentityProof,
VerifiedSubmissionProof,
VerifiedTrustedEndUserProof,
)
from core.human_input_v2.entities import (
HumanInputApproverGrantSubjectType,
HumanInputSubmissionActorType,
)
from core.human_input_v2.shared import (
AccountId,
AppId,
ApproverGrantId,
AuditEventId,
ContactId,
DeliveryEndpointId,
EndUserId,
FormId,
IMBindingId,
IMIdentityId,
IntegrationId,
NormalizedEmail,
OTPChallengeId,
SubmissionId,
UtcTimestamp,
WorkspaceId,
)
from models.human_input_v2 import (
AccountSessionAuthorizationProof,
EmailOTPAuthorizationProof,
FormAuditEventPayload,
FormAuthorizationProof,
FormCanonicalValues,
FormInputSnapshot,
HumanInputV2FormAuditEvent,
HumanInputV2FormSubmission,
IMIdentityAuthorizationProof,
TrustedEndUserAuthorizationProof,
)
def _timestamp(value: datetime) -> UtcTimestamp:
return UtcTimestamp(value.replace(tzinfo=UTC) if value.tzinfo is None else value)
def proof_to_record_value(proof: VerifiedSubmissionProof) -> FormAuthorizationProof:
"""Serialize verified evidence without introducing reusable credentials."""
match proof:
case VerifiedAccountSessionProof(account_id=account_id):
return AccountSessionAuthorizationProof(account_id=str(account_id))
case VerifiedTrustedEndUserProof(end_user_id=end_user_id, app_id=app_id):
return TrustedEndUserAuthorizationProof(app_id=str(app_id), end_user_id=str(end_user_id))
case VerifiedEmailOTPProof() as email_proof:
subject_type = HumanInputApproverGrantSubjectType.EMAIL_ADDRESS
contact_id: str | None = None
if isinstance(email_proof.subject, ContactOTPSubject):
subject_type = HumanInputApproverGrantSubjectType.CONTACT
contact_id = str(email_proof.subject.contact_id)
return EmailOTPAuthorizationProof(
otp_challenge_id=str(email_proof.challenge_ref.challenge_id),
workspace_id=str(email_proof.challenge_ref.form_ref.workspace_id),
form_id=str(email_proof.challenge_ref.form_ref.form_id),
approver_grant_id=str(email_proof.challenge_ref.grant_ref.grant_id),
subject_type=subject_type,
contact_id=contact_id,
verified_email=str(email_proof.normalized_email),
verified_at=email_proof.verified_at.value,
)
case VerifiedIMIdentityProof() as im_proof:
return IMIdentityAuthorizationProof(
integration_id=str(im_proof.integration_id),
im_identity_id=str(im_proof.identity_id),
im_binding_id=str(im_proof.binding_id) if im_proof.binding_id is not None else None,
provider=im_proof.provider,
provider_tenant_id=im_proof.provider_tenant_id,
provider_user_id=im_proof.provider_user_id,
)
assert_never(proof)
def proof_from_record_value(
record_value: FormAuthorizationProof,
*,
workspace_id: WorkspaceId,
) -> VerifiedSubmissionProof:
"""Rebuild verified evidence using the owner scope from its audit record."""
match record_value:
case AccountSessionAuthorizationProof(account_id=account_id):
return VerifiedAccountSessionProof(AccountId(account_id))
case TrustedEndUserAuthorizationProof(app_id=app_id, end_user_id=end_user_id):
return VerifiedTrustedEndUserProof(EndUserId(end_user_id), AppId(app_id))
case EmailOTPAuthorizationProof() as email_proof:
if email_proof.workspace_id != str(workspace_id):
raise ValueError("authorized Email proof owner does not match the audit event")
normalized_email = NormalizedEmail(email_proof.verified_email)
subject: EmailOTPSubject
if email_proof.subject_type is HumanInputApproverGrantSubjectType.CONTACT:
if email_proof.contact_id is None:
raise ValueError("contact Email proof record is missing contact_id")
subject = ContactOTPSubject(ContactId(email_proof.contact_id))
elif email_proof.subject_type is HumanInputApproverGrantSubjectType.EMAIL_ADDRESS:
if email_proof.contact_id is not None:
raise ValueError("EmailAddress proof record must not contain contact_id")
subject = EmailAddressOTPSubject(normalized_email)
else:
raise ValueError("Email proof record has an unsupported subject type")
challenge_ref = (
FormRef(workspace_id, FormId(email_proof.form_id))
.grant(ApproverGrantId(email_proof.approver_grant_id))
.challenge(OTPChallengeId(email_proof.otp_challenge_id))
)
return VerifiedEmailOTPProof(
challenge_ref=challenge_ref,
subject=subject,
normalized_email=normalized_email,
verified_at=_timestamp(email_proof.verified_at),
)
case IMIdentityAuthorizationProof() as im_proof:
return VerifiedIMIdentityProof(
integration_id=IntegrationId(im_proof.integration_id),
identity_id=IMIdentityId(im_proof.im_identity_id),
binding_id=IMBindingId(im_proof.im_binding_id) if im_proof.im_binding_id is not None else None,
provider=im_proof.provider,
provider_tenant_id=im_proof.provider_tenant_id,
provider_user_id=im_proof.provider_user_id,
)
assert_never(record_value)
@dataclass(frozen=True, slots=True)
class SubmissionActorRecordFields:
"""Exactly one populated actor column selected by its discriminator."""
actor_type: HumanInputSubmissionActorType
account_id: str | None
end_user_id: str | None
normalized_email: str | None
def _actor_to_record_fields(actor: SubmissionActor) -> SubmissionActorRecordFields:
match actor:
case AccountSubmissionActor(account_id=account_id):
return SubmissionActorRecordFields(HumanInputSubmissionActorType.ACCOUNT, str(account_id), None, None)
case EndUserSubmissionActor(end_user_id=end_user_id):
return SubmissionActorRecordFields(HumanInputSubmissionActorType.END_USER, None, str(end_user_id), None)
case EmailAddressSubmissionActor(normalized_email=normalized_email):
return SubmissionActorRecordFields(
HumanInputSubmissionActorType.EMAIL_ADDRESS,
None,
None,
str(normalized_email),
)
assert_never(actor)
def _actor_from_record(record: HumanInputV2FormSubmission) -> SubmissionActor:
actor_columns = (
record.actor_account_id is not None,
record.actor_end_user_id is not None,
record.actor_normalized_email is not None,
)
if sum(actor_columns) != 1:
raise ValueError("submission actor columns must contain exactly one identity")
if record.actor_type is HumanInputSubmissionActorType.ACCOUNT and record.actor_account_id is not None:
return AccountSubmissionActor(AccountId(record.actor_account_id))
if record.actor_type is HumanInputSubmissionActorType.END_USER and record.actor_end_user_id is not None:
return EndUserSubmissionActor(EndUserId(record.actor_end_user_id))
if record.actor_type is HumanInputSubmissionActorType.EMAIL_ADDRESS and record.actor_normalized_email is not None:
return EmailAddressSubmissionActor(NormalizedEmail(record.actor_normalized_email))
raise ValueError("submission actor discriminator does not match its actor columns")
def submission_to_record(submission: FormSubmission) -> HumanInputV2FormSubmission:
"""Map one detached immutable winning submission to an ORM record."""
actor_fields = _actor_to_record_fields(submission.actor)
record = HumanInputV2FormSubmission(
tenant_id=str(submission.form_ref.workspace_id),
form_id=str(submission.form_ref.form_id),
approver_grant_id=str(submission.approver_grant_id),
actor_type=actor_fields.actor_type,
authorization_audit_event_id=str(submission.authorization_audit_event_id),
selected_action_id=submission.selected_action_id,
input_snapshot=FormInputSnapshot(submission.input_snapshot.to_mapping()),
canonical_values=FormCanonicalValues(submission.canonical_values.to_mapping()),
submitted_at=submission.submitted_at.value,
actor_account_id=actor_fields.account_id,
actor_end_user_id=actor_fields.end_user_id,
actor_normalized_email=actor_fields.normalized_email,
endpoint_id=str(submission.endpoint_id) if submission.endpoint_id is not None else None,
)
record.id = str(submission.id)
record.created_at = submission.created_at.value
record.updated_at = submission.updated_at.value
return record
def submission_from_record(record: HumanInputV2FormSubmission) -> FormSubmission:
"""Rebuild one submission while validating its actor and structured values."""
if not isinstance(record.input_snapshot, FormInputSnapshot) or not isinstance(
record.canonical_values, FormCanonicalValues
):
raise ValueError("submission record has malformed structured values")
return FormSubmission(
id=SubmissionId(record.id),
form_ref=FormRef(WorkspaceId(record.tenant_id), FormId(record.form_id)),
approver_grant_id=ApproverGrantId(record.approver_grant_id),
endpoint_id=DeliveryEndpointId(record.endpoint_id) if record.endpoint_id is not None else None,
authorization_audit_event_id=AuditEventId(record.authorization_audit_event_id),
actor=_actor_from_record(record),
selected_action_id=record.selected_action_id,
input_snapshot=FrozenJSONObject.from_mapping(record.input_snapshot.root),
canonical_values=FrozenJSONObject.from_mapping(record.canonical_values.root),
submitted_at=_timestamp(record.submitted_at),
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def audit_event_to_record(event: FormAuthorizationAuditEvent) -> HumanInputV2FormAuditEvent:
"""Map one immutable shared audit fact to its append-only record."""
event.validate_authorization_proof_owner()
record = HumanInputV2FormAuditEvent(
tenant_id=str(event.form_ref.workspace_id),
form_id=str(event.form_ref.form_id),
event_type=event.event_type.value,
occurred_at=event.occurred_at.value,
approver_grant_id=str(event.approver_grant_id) if event.approver_grant_id is not None else None,
endpoint_id=str(event.endpoint_id) if event.endpoint_id is not None else None,
channel=event.channel,
reason_code=event.reason_code,
reason_message=event.reason_message,
authorization_proof=(
proof_to_record_value(event.authorization_proof) if event.authorization_proof is not None else None
),
event_payload=FormAuditEventPayload(event.payload.to_mapping()) if event.payload is not None else None,
)
record.id = str(event.id)
record.created_at = event.created_at.value
record.updated_at = event.updated_at.value
return record
def audit_event_from_record(record: HumanInputV2FormAuditEvent) -> FormAuthorizationAuditEvent:
"""Rebuild one audit fact while enforcing authorized/rejected semantics."""
try:
event_type = FormAuthorizationAuditEventType(record.event_type)
except ValueError as error:
raise ValueError("audit record has an unsupported event type") from error
proof = (
proof_from_record_value(record.authorization_proof, workspace_id=WorkspaceId(record.tenant_id))
if record.authorization_proof is not None
else None
)
if event_type is FormAuthorizationAuditEventType.SUBMISSION_AUTHORIZED and proof is None:
raise ValueError("authorized audit record requires verified proof")
if record.event_payload is not None and not isinstance(record.event_payload, FormAuditEventPayload):
raise ValueError("audit record has a malformed structured payload")
return FormAuthorizationAuditEvent(
id=AuditEventId(record.id),
event_type=event_type,
form_ref=FormRef(WorkspaceId(record.tenant_id), FormId(record.form_id)),
approver_grant_id=(ApproverGrantId(record.approver_grant_id) if record.approver_grant_id is not None else None),
endpoint_id=DeliveryEndpointId(record.endpoint_id) if record.endpoint_id is not None else None,
channel=record.channel,
reason_code=record.reason_code,
reason_message=record.reason_message,
authorization_proof=proof,
payload=(
FrozenJSONObject.from_mapping(record.event_payload.root) if record.event_payload is not None else None
),
occurred_at=_timestamp(record.occurred_at),
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
__all__ = [
"audit_event_from_record",
"audit_event_to_record",
"proof_from_record_value",
"proof_to_record_value",
"submission_from_record",
"submission_to_record",
]
@@ -0,0 +1,54 @@
"""Transaction-scoped OTP issuance writer for the shared form audit table.
The writer only adds a mapped record to the caller-owned Session. It never
flushes or commits, so a later OTP challenge failure rolls the audit fact back
with the preceding proof-session transaction.
"""
from sqlalchemy.orm import Session
from core.human_input_v2.approval import (
FormAuthorizationAuditEvent,
FormAuthorizationAuditEventType,
FrozenJSONObject,
)
from core.human_input_v2.entities import HumanInputDeliveryChannel
from core.human_input_v2.shared import AuditEventId
from repositories.human_input_v2.approval import OTPChallengeAuditFact
from .mappers import audit_event_to_record
class SQLAlchemyOTPChallengeAuditWriter:
"""Map one secret-free OTP issuance fact into the caller transaction."""
def append(self, session: Session, fact: OTPChallengeAuditFact) -> None:
"""Add the shared audit record without changing transaction ownership."""
event = FormAuthorizationAuditEvent(
id=AuditEventId(fact.audit_event_id),
event_type=FormAuthorizationAuditEventType.OTP_CHALLENGE_ISSUED,
form_ref=fact.challenge_ref.form_ref,
approver_grant_id=fact.challenge_ref.grant_ref.grant_id,
endpoint_id=None,
channel=HumanInputDeliveryChannel.EMAIL,
reason_code=None,
reason_message=None,
authorization_proof=None,
payload=FrozenJSONObject.from_mapping(
{
"challenge_id": str(fact.challenge_ref.challenge_id),
"previous_challenge_id": (
str(fact.previous_challenge_id) if fact.previous_challenge_id is not None else None
),
"send_count": fact.send_count,
}
),
occurred_at=fact.occurred_at,
created_at=fact.occurred_at,
updated_at=fact.occurred_at,
)
session.add(audit_event_to_record(event))
__all__ = ["SQLAlchemyOTPChallengeAuditWriter"]
@@ -0,0 +1,463 @@
"""Form-locked SQLAlchemy adapter for Human Input v2 first-success submission.
One repeatable-read transaction locks the tenant-owned Form, loads the target
grant, endpoint, and current identity facts, then keeps that immutable context
authoritative for the write set. The commit path never reloads Contact or IM
binding state. Audit insert, unique Submission insert, and Form transition share
one savepoint so a unique-form race becomes a stable loser result without
retaining its audit.
"""
from __future__ import annotations
from contextlib import contextmanager
import sqlalchemy as sa
from sqlalchemy import select
from sqlalchemy.exc import DBAPIError, IntegrityError, SQLAlchemyError
from sqlalchemy.orm import Session, sessionmaker
from core.human_input_v2.approval import (
AuthorizationContext,
AuthorizedSubmissionCommit,
ContactApprovalSubject,
CurrentContactAuthorizationFacts,
CurrentEndUserAuthorizationFacts,
CurrentIMAuthorizationFacts,
EndUserApprovalSubject,
FormAuthorizationAuditEvent,
FormAuthorizationAuditEventType,
FrozenJSONObject,
RetryableSubmissionPersistenceError,
SubmissionAttemptScope,
SubmissionCommitResult,
SubmissionCommitStatus,
VerifiedIMIdentityProof,
)
from core.human_input_v2.entities import HumanInputV2FormStatus, IMBindingScope
from core.human_input_v2.shared import (
AccountId,
AppId,
ContactId,
EndUserId,
IMBindingId,
IMIdentityId,
IntegrationId,
NormalizedEmail,
)
from models.account import Account, AccountStatus, TenantAccountJoin
from models.human_input_v2 import (
HumanInputContact,
HumanInputContactIdentitySource,
HumanInputIMBinding,
HumanInputIMIdentity,
HumanInputIMIntegration,
HumanInputPlatformContactWorkspaceEntry,
HumanInputV2Form,
HumanInputV2FormApproverGrant,
HumanInputV2FormDeliveryEndpoint,
)
from models.model import EndUser
from repositories.human_input_v2.form.mappers import endpoint_from_record, form_from_record, grant_from_record
from .mappers import audit_event_to_record, submission_to_record
_POSTGRESQL_SERIALIZATION_FAILURE_SQLSTATE = "40001"
class SubmissionPersistenceError(RuntimeError):
"""A submission transaction failed and rolled back its complete write set."""
class SubmissionScopeNotFoundError(ValueError):
"""The complete tenant/form/grant/endpoint owner chain does not exist."""
class SQLAlchemySubmissionRepository:
"""Create short session-bound transactions for the atomic submission use case."""
_session_maker: sessionmaker[Session]
def __init__(self, session_maker: sessionmaker[Session]) -> None:
self._session_maker = session_maker
@contextmanager
def transaction(self, scope: SubmissionAttemptScope):
"""Commit on clean exit and translate infrastructure failures after rollback."""
try:
with self._session_maker() as session, session.begin():
self._configure_snapshot_transaction(session)
yield SQLAlchemySubmissionTransaction(session, scope)
except SubmissionPersistenceError as error:
if _is_postgresql_serialization_failure(error):
raise RetryableSubmissionPersistenceError("submission transaction must be retried") from error
raise
except SQLAlchemyError as error:
if _is_postgresql_serialization_failure(error):
raise RetryableSubmissionPersistenceError("submission transaction must be retried") from error
raise SubmissionPersistenceError("submission transaction failed") from error
@staticmethod
def _configure_snapshot_transaction(session: Session) -> None:
"""Establish one MVCC snapshot before the transaction's first query."""
if session.get_bind().dialect.name in {"mysql", "postgresql"}:
session.connection(execution_options={"isolation_level": "REPEATABLE READ"})
class SQLAlchemySubmissionTransaction:
"""Session-bound owner of one coherent load and at most one write outcome."""
_session: Session
_scope: SubmissionAttemptScope
_form_record: HumanInputV2Form | None
_context: AuthorizationContext | None
def __init__(self, session: Session, scope: SubmissionAttemptScope) -> None:
self._session = session
self._scope = scope
self._form_record = None
self._context = None
@staticmethod
def locked_form_statement(scope: SubmissionAttemptScope) -> sa.Select[tuple[HumanInputV2Form]]:
"""Select exactly one tenant-owned Form under the first-success row lock."""
return (
select(HumanInputV2Form)
.where(
HumanInputV2Form.tenant_id == str(scope.form_ref.workspace_id),
HumanInputV2Form.id == str(scope.form_ref.form_id),
)
.with_for_update()
)
def load_authorization_context(self, *, proof: object) -> AuthorizationContext:
"""Load all relevant current facts once through the active transaction."""
if self._context is not None:
return self._context
form_record = self._session.scalar(self.locked_form_statement(self._scope))
if form_record is None:
raise SubmissionScopeNotFoundError("form owner scope does not exist")
grant_record = self._session.scalar(
select(HumanInputV2FormApproverGrant).where(
HumanInputV2FormApproverGrant.tenant_id == str(self._scope.form_ref.workspace_id),
HumanInputV2FormApproverGrant.form_id == str(self._scope.form_ref.form_id),
HumanInputV2FormApproverGrant.id == str(self._scope.approver_grant_id),
)
)
if grant_record is None:
raise SubmissionScopeNotFoundError("approver grant owner scope does not exist")
endpoint_record = self._load_endpoint_record()
form = form_from_record(form_record, (grant_record,))
grant = grant_from_record(grant_record)
endpoint = endpoint_from_record(endpoint_record) if endpoint_record is not None else None
current_contact = self._load_current_contact(grant)
current_end_user = self._load_current_end_user(grant, form.app_id)
current_im = self._load_current_im_binding(proof, current_contact)
context = AuthorizationContext(
form=form,
grant=grant,
endpoint=endpoint,
current_contact=current_contact,
current_end_user=current_end_user,
current_im_binding=current_im,
)
self._form_record = form_record
self._context = context
return context
def append_rejection_audit(self, event: FormAuthorizationAuditEvent) -> None:
"""Append one scoped rejection fact without changing Form lifecycle."""
self._require_loaded_context()
if event.event_type is not FormAuthorizationAuditEventType.SUBMISSION_REJECTED:
raise ValueError("rejection append requires a submission_rejected event")
self._validate_event_owner(event)
try:
self._session.add(audit_event_to_record(event))
self._session.flush()
except SQLAlchemyError as error:
raise SubmissionPersistenceError("failed to append submission rejection audit") from error
def commit_authorized_submission_once(
self,
commit: AuthorizedSubmissionCommit,
) -> SubmissionCommitResult:
"""Atomically insert authorized audit/submission and transition the locked Form."""
context = self._require_loaded_context()
form_record = self._form_record
assert form_record is not None
self._validate_authorized_owner(commit, context)
if form_record.status is not HumanInputV2FormStatus.WAITING:
return SubmissionCommitResult(SubmissionCommitStatus.ALREADY_COMPLETED, None)
occurred_at = commit.authorized.transition.decided_at
channel = context.endpoint.channel if context.endpoint is not None else None
audit_event = FormAuthorizationAuditEvent(
id=commit.authorization_audit_event_id,
event_type=FormAuthorizationAuditEventType.SUBMISSION_AUTHORIZED,
form_ref=self._scope.form_ref,
approver_grant_id=self._scope.approver_grant_id,
endpoint_id=self._scope.endpoint_id,
channel=channel,
reason_code=None,
reason_message=None,
authorization_proof=commit.authorized.proof,
payload=FrozenJSONObject.from_mapping(
{"selected_action_id": commit.authorized.transition.selected_action_id}
),
occurred_at=occurred_at,
created_at=occurred_at,
updated_at=occurred_at,
)
submission = commit.to_submission(
form_ref=self._scope.form_ref,
approver_grant_id=self._scope.approver_grant_id,
endpoint_id=self._scope.endpoint_id,
submitted_at=occurred_at,
)
try:
with self._session.begin_nested():
self._session.add(audit_event_to_record(audit_event))
self._session.flush()
self._session.add(submission_to_record(submission))
self._session.flush()
form_record.status = HumanInputV2FormStatus.SUBMITTED
form_record.updated_at = occurred_at.value
self._session.flush()
except IntegrityError as error:
if self._is_form_submission_unique_conflict(error):
return SubmissionCommitResult(SubmissionCommitStatus.ALREADY_COMPLETED, None)
raise SubmissionPersistenceError("authorized submission violated a persistence invariant") from error
except SQLAlchemyError as error:
raise SubmissionPersistenceError("failed to commit authorized submission") from error
return SubmissionCommitResult(SubmissionCommitStatus.COMMITTED, submission)
def _load_endpoint_record(self) -> HumanInputV2FormDeliveryEndpoint | None:
if self._scope.endpoint_id is None:
return None
endpoint_record = self._session.scalar(
select(HumanInputV2FormDeliveryEndpoint).where(
HumanInputV2FormDeliveryEndpoint.tenant_id == str(self._scope.form_ref.workspace_id),
HumanInputV2FormDeliveryEndpoint.form_id == str(self._scope.form_ref.form_id),
HumanInputV2FormDeliveryEndpoint.approver_grant_id == str(self._scope.approver_grant_id),
HumanInputV2FormDeliveryEndpoint.id == str(self._scope.endpoint_id),
)
)
if endpoint_record is None:
raise SubmissionScopeNotFoundError("delivery endpoint owner scope does not exist")
return endpoint_record
def _load_current_contact(self, grant) -> CurrentContactAuthorizationFacts | None:
if not isinstance(grant.subject, ContactApprovalSubject):
return None
workspace_id = str(self._scope.form_ref.workspace_id)
membership_exists = sa.exists().where(
TenantAccountJoin.tenant_id == workspace_id,
TenantAccountJoin.account_id == HumanInputContact.account_id,
)
platform_exists = sa.exists().where(
HumanInputPlatformContactWorkspaceEntry.tenant_id == workspace_id,
HumanInputPlatformContactWorkspaceEntry.contact_id == HumanInputContact.id,
)
row = self._session.execute(
select(HumanInputContact, Account.status, membership_exists, platform_exists)
.outerjoin(Account, Account.id == HumanInputContact.account_id)
.where(
HumanInputContact.id == str(grant.subject.contact_id),
sa.or_(HumanInputContact.tenant_id == workspace_id, HumanInputContact.tenant_id.is_(None)),
)
).one_or_none()
if row is None:
return None
contact_record, account_status, has_membership, has_platform_entry = row
identity_source = contact_record.identity_source
if identity_source is HumanInputContactIdentitySource.EXTERNAL:
workspace_available = contact_record.tenant_id == workspace_id
elif identity_source is HumanInputContactIdentitySource.WORKSPACE_MEMBER:
workspace_available = contact_record.tenant_id == workspace_id and has_membership
else:
workspace_available = has_membership or has_platform_entry
normalized_email = (
NormalizedEmail(contact_record.normalized_email) if contact_record.normalized_email is not None else None
)
return CurrentContactAuthorizationFacts(
contact_id=ContactId(contact_record.id),
account_id=AccountId(contact_record.account_id) if contact_record.account_id is not None else None,
normalized_email=normalized_email,
account_active=contact_record.account_id is None or account_status is AccountStatus.ACTIVE,
workspace_available=bool(workspace_available),
)
def _load_current_end_user(self, grant, app_id: AppId) -> CurrentEndUserAuthorizationFacts | None:
if not isinstance(grant.subject, EndUserApprovalSubject):
return None
end_user_id = str(grant.subject.end_user_id)
workspace_id = str(self._scope.form_ref.workspace_id)
row = self._session.execute(
select(EndUser.id, EndUser.app_id).where(
EndUser.id == end_user_id,
EndUser.tenant_id == workspace_id,
EndUser.app_id == str(app_id),
)
).one_or_none()
if row is None:
return None
return CurrentEndUserAuthorizationFacts(
end_user_id=EndUserId(row.id),
app_id=AppId(row.app_id),
workspace_available=True,
)
def _load_current_im_binding(
self,
proof: object,
current_contact: CurrentContactAuthorizationFacts | None,
) -> CurrentIMAuthorizationFacts | None:
if not isinstance(proof, VerifiedIMIdentityProof) or current_contact is None:
return None
workspace_id = str(self._scope.form_ref.workspace_id)
integration = self._session.scalar(
select(HumanInputIMIntegration).where(
HumanInputIMIntegration.id == str(proof.integration_id),
HumanInputIMIntegration.provider == proof.provider,
sa.or_(HumanInputIMIntegration.tenant_id == workspace_id, HumanInputIMIntegration.tenant_id.is_(None)),
)
)
if integration is None:
return None
priority = sa.case((HumanInputIMBinding.scope == IMBindingScope.WORKSPACE, 0), else_=1)
binding_row = self._session.execute(
select(HumanInputIMBinding, HumanInputIMIdentity)
.outerjoin(
HumanInputIMIdentity,
HumanInputIMIdentity.id == HumanInputIMBinding.im_identity_id,
)
.where(
HumanInputIMBinding.integration_id == integration.id,
HumanInputIMBinding.provider == proof.provider,
HumanInputIMBinding.contact_id == str(current_contact.contact_id),
sa.or_(
sa.and_(
HumanInputIMBinding.scope == IMBindingScope.WORKSPACE,
HumanInputIMBinding.scope_id == workspace_id,
),
sa.and_(
HumanInputIMBinding.scope == IMBindingScope.ORGANIZATION,
HumanInputIMBinding.scope_id == integration.id,
),
),
)
.order_by(priority, HumanInputIMBinding.id)
.limit(1)
).one_or_none()
binding_id: IMBindingId | None = None
if binding_row is not None:
binding_record, identity_record = binding_row
if binding_record.integration_id != integration.id or binding_record.provider is not proof.provider:
return None
if (
identity_record is None
or identity_record.integration_id != integration.id
or identity_record.provider is not proof.provider
):
return None
binding_id = IMBindingId(binding_record.id)
elif current_contact.normalized_email is not None:
identity_record = self._session.scalar(
select(HumanInputIMIdentity).where(
HumanInputIMIdentity.integration_id == integration.id,
HumanInputIMIdentity.provider == proof.provider,
HumanInputIMIdentity.normalized_email == str(current_contact.normalized_email),
)
)
if identity_record is None:
return None
else:
return None
return CurrentIMAuthorizationFacts(
integration_id=IntegrationId(integration.id),
provider=integration.provider,
provider_tenant_id=integration.provider_tenant_id,
contact_id=current_contact.contact_id,
account_id=current_contact.account_id,
identity_id=IMIdentityId(identity_record.id),
binding_id=binding_id,
provider_user_id=identity_record.provider_user_id,
)
def _require_loaded_context(self) -> AuthorizationContext:
if self._context is None:
raise RuntimeError("authorization context must be loaded before persistence")
return self._context
def _validate_event_owner(self, event: FormAuthorizationAuditEvent) -> None:
if (
event.form_ref != self._scope.form_ref
or event.approver_grant_id != self._scope.approver_grant_id
or event.endpoint_id != self._scope.endpoint_id
):
raise ValueError("audit event does not match the loaded owner scope")
def _validate_authorized_owner(
self,
commit: AuthorizedSubmissionCommit,
context: AuthorizationContext,
) -> None:
transition = commit.authorized.transition
if (
transition.form_ref != self._scope.form_ref
or transition.grant_id != self._scope.approver_grant_id
or commit.authorized.endpoint_ref != (context.endpoint.ref if context.endpoint is not None else None)
):
raise ValueError("authorized submission does not match the loaded owner scope")
@staticmethod
def _is_form_submission_unique_conflict(error: IntegrityError) -> bool:
message = str(error.orig).lower()
return "hiv2_form_submissions_form_uq" in message or "human_input_v2_form_submissions.form_id" in message
def _is_postgresql_serialization_failure(error: BaseException) -> bool:
"""Inspect SQLAlchemy and public driver exception links for SQLSTATE 40001."""
pending: list[object] = [error]
visited: set[int] = set()
while pending:
current = pending.pop()
current_id = id(current)
if current_id in visited:
continue
visited.add(current_id)
if _structured_sqlstate(current) == _POSTGRESQL_SERIALIZATION_FAILURE_SQLSTATE:
return True
if isinstance(current, DBAPIError):
pending.append(current.orig)
if isinstance(current, BaseException):
if current.__cause__ is not None:
pending.append(current.__cause__)
if current.__context__ is not None:
pending.append(current.__context__)
return False
def _structured_sqlstate(error: object) -> str | None:
"""Read documented psycopg/psycopg2 SQLSTATE attributes at the driver boundary."""
for attribute_name in ("sqlstate", "pgcode"):
raw_code = getattr(error, attribute_name, None)
if isinstance(raw_code, str):
return raw_code
return None
__all__ = [
"SQLAlchemySubmissionRepository",
"SQLAlchemySubmissionTransaction",
"SubmissionPersistenceError",
"SubmissionScopeNotFoundError",
]
+1 -3
View File
@@ -1599,9 +1599,7 @@ class TenantService:
return updated_accounts
@staticmethod
def iter_member_account_id_batches(
tenant_id: str, batch_size: int, *, session: Session
) -> Iterator[list[str]]:
def iter_member_account_id_batches(tenant_id: str, batch_size: int, *, session: Session) -> Iterator[list[str]]:
"""Yield workspace member account ids in bounded, ordered batches."""
offset = 0
while True:
+20 -46
View File
@@ -19,11 +19,12 @@ from __future__ import annotations
import hashlib
import io
import posixpath
import re
import zipfile
import zlib
import yaml
from pydantic import BaseModel, Field, ValidationError, field_validator
from pydantic import BaseModel
# Bounds — generous but finite so a hostile upload can't exhaust memory/disk.
_MAX_ARCHIVE_BYTES = 50 * 1024 * 1024
@@ -32,8 +33,7 @@ _MAX_SKILL_MD_BYTES = 1 * 1024 * 1024
_MAX_ENTRIES = 5000
_ALLOWED_EXTENSIONS = (".zip", ".skill")
_SKILL_MD_NAME = "SKILL.md"
_SKILL_NAME_PATTERN = r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
_MAX_SKILL_DESCRIPTION_LENGTH = 1024
_HEADING_RE = re.compile(r"^\s*#\s+(.+?)\s*$", re.MULTILINE)
class SkillPackageError(Exception):
@@ -53,18 +53,13 @@ class SkillPackageError(Exception):
class SkillManifest(BaseModel):
"""Validated metadata extracted from a Skill package."""
name: str = Field(min_length=1, max_length=64, pattern=_SKILL_NAME_PATTERN)
description: str = Field(min_length=1, max_length=_MAX_SKILL_DESCRIPTION_LENGTH)
name: str
description: str
entry_path: str # path of SKILL.md inside the archive
files: list[str] # all (safe) file paths inside the archive
size: int # total uncompressed bytes
hash: str # sha256 of the archive bytes
@field_validator("name", "description", mode="before")
@classmethod
def _strip_required_string(cls, value: object) -> object:
return value.strip() if isinstance(value, str) else value
class NormalizedSkillPackage(BaseModel):
"""Canonical skill package bytes and metadata ready to store in agent drive."""
@@ -113,17 +108,14 @@ class SkillPackageService:
normalized_size = sum(max(info.file_size, 0) for info in normalized_members.values())
name, description = self._parse_skill_md(skill_md)
try:
manifest = SkillManifest(
name=name,
description=description,
entry_path=_SKILL_MD_NAME,
files=sorted(normalized_members),
size=normalized_size,
hash=hashlib.sha256(normalized_archive_bytes).hexdigest(),
)
except ValidationError as exc:
raise self._manifest_validation_error(exc) from exc
manifest = SkillManifest(
name=name,
description=description,
entry_path=_SKILL_MD_NAME,
files=sorted(normalized_members),
size=normalized_size,
hash=hashlib.sha256(normalized_archive_bytes).hexdigest(),
)
return NormalizedSkillPackage(
manifest=manifest,
archive_bytes=normalized_archive_bytes,
@@ -131,31 +123,6 @@ class SkillPackageService:
strip_prefix=strip_prefix,
)
@staticmethod
def _manifest_validation_error(exc: ValidationError) -> SkillPackageError:
first_error = exc.errors()[0]
loc = first_error["loc"]
field = loc[0] if loc else "manifest"
error_type = first_error["type"]
if field == "name":
code = "missing_skill_name" if error_type == "string_too_short" else "invalid_skill_name"
message = (
"SKILL.md frontmatter name is required"
if code == "missing_skill_name"
else "SKILL.md frontmatter name must be lowercase letters, numbers, and hyphens only, "
"must not start or end with a hyphen, and must be at most 64 characters"
)
return SkillPackageError(code, message, status_code=400)
if field == "description":
code = "missing_skill_description" if error_type == "string_too_short" else "invalid_skill_description"
message = (
"SKILL.md frontmatter description is required"
if code == "missing_skill_description"
else f"SKILL.md frontmatter description must be at most {_MAX_SKILL_DESCRIPTION_LENGTH} characters"
)
return SkillPackageError(code, message, status_code=400)
return SkillPackageError("invalid_skill_manifest", "SKILL.md frontmatter is invalid", status_code=400)
def _open_archive(self, *, content: bytes, filename: str) -> zipfile.ZipFile:
self._check_extension(filename)
if not content:
@@ -313,6 +280,13 @@ class SkillPackageService:
frontmatter = cls._parse_frontmatter(content)
name = str(frontmatter.get("name") or "").strip()
description = str(frontmatter.get("description") or "").strip()
if not name:
heading = _HEADING_RE.search(content)
name = heading.group(1).strip() if heading else ""
if not name:
raise SkillPackageError(
"missing_skill_name", "SKILL.md must declare a name (frontmatter or top heading)", status_code=400
)
return name, description
@staticmethod
+12 -81
View File
@@ -46,7 +46,6 @@ from models.tools import ToolFile
from services.agent.config_skill_normalize_service import ConfigSkillNormalizeService
from services.agent.skill_package_service import SkillPackageError
from services.agent_drive_service import DriveFileRef
from services.skill_management_service import SkillManagementService, SkillManagementServiceError
class AgentConfigVersionKind(StrEnum):
@@ -99,7 +98,6 @@ class ConfigPushPayload(BaseModel):
@dataclass(slots=True)
class AgentConfigTarget:
tenant_id: str
agent_id: str
version_id: str
kind: AgentConfigVersionKind
@@ -148,7 +146,6 @@ class AgentConfigService:
user_id=user_id,
)
return AgentConfigTarget(
tenant_id=tenant_id,
agent_id=target.agent_id,
version_id=target.version_id,
kind=target.kind,
@@ -194,7 +191,7 @@ class AgentConfigService:
return {
"agent_id": target.agent_id,
"config_version": self._config_version_payload(target),
"items": self._skill_items_for_target(target),
"items": [self._serialize_skill_item(skill) for skill in target.agent_soul.config_skills],
}
def list_files(
@@ -236,27 +233,10 @@ class AgentConfigService:
config_version_kind=config_version_kind,
user_id=user_id,
)
try:
skill = self._require_skill(target.agent_soul, name=name)
file_id = self._available_skill_file_id(skill)
payload, mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
return ConfigDownload(
filename=f"{skill.name}.zip",
mime_type=mime_type or "application/zip",
payload=payload,
)
except AgentConfigServiceError as exc:
if exc.code != "config_skill_not_found":
raise
try:
result = SkillManagementService().pull_runtime_agent_skill(
tenant_id=tenant_id,
agent_id=agent_id,
name=name,
)
return ConfigDownload(filename=result.filename, mime_type=result.mime_type, payload=result.payload)
except SkillManagementServiceError as exc:
raise AgentConfigServiceError("config_skill_not_found", "config skill not found", status_code=404) from exc
skill = self._require_skill(target.agent_soul, name=name)
file_id = self._available_skill_file_id(skill)
payload, mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
return ConfigDownload(filename=f"{skill.name}.zip", mime_type=mime_type or "application/zip", payload=payload)
def download_skill_url(
self,
@@ -299,45 +279,9 @@ class AgentConfigService:
config_version_kind=config_version_kind,
user_id=user_id,
)
try:
skill = self._require_skill(target.agent_soul, name=name)
file_id = self._available_skill_file_id(skill)
archive_bytes, _mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
skill_item = self._serialize_skill_item(skill)
except AgentConfigServiceError as exc:
if exc.code != "config_skill_not_found":
raise
try:
workspace_archive = SkillManagementService().pull_runtime_agent_skill(
tenant_id=tenant_id,
agent_id=agent_id,
name=name,
)
except SkillManagementServiceError as skill_exc:
raise AgentConfigServiceError(
"config_skill_not_found",
"config skill not found",
status_code=404,
) from skill_exc
archive_bytes = workspace_archive.payload
skill_item = next(
(
item
for item in SkillManagementService().list_runtime_agent_skills(
tenant_id=tenant_id,
agent_id=agent_id,
)
if item["name"] == name
),
{
"id": name,
"name": name,
"description": "",
"size": None,
"hash": None,
"mime_type": "application/zip",
},
)
skill = self._require_skill(target.agent_soul, name=name)
file_id = self._available_skill_file_id(skill)
archive_bytes, _mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
try:
archive_items, skill_md = self._inspect_skill_archive(archive_bytes)
except (OSError, ValueError, zipfile.BadZipFile) as exc:
@@ -347,7 +291,7 @@ class AgentConfigService:
status_code=500,
) from exc
return {
**skill_item,
**self._serialize_skill_item(skill),
"source": "config_skill_zip",
"files": archive_items,
"skill_md": skill_md,
@@ -895,7 +839,6 @@ class AgentConfigService:
status_code=404,
)
return AgentConfigTarget(
tenant_id=tenant_id,
agent_id=agent_id,
version_id=version.id,
kind=config_version_kind,
@@ -1190,7 +1133,9 @@ class AgentConfigService:
return {
"agent_id": target.agent_id,
"config_version": AgentConfigService._config_version_payload(target),
"skills": {"items": AgentConfigService._skill_items_for_target(target)},
"skills": {
"items": [AgentConfigService._serialize_skill_item(skill) for skill in target.agent_soul.config_skills]
},
"files": {
"items": [
AgentConfigService._serialize_file_item(file_ref) for file_ref in target.agent_soul.config_files
@@ -1200,20 +1145,6 @@ class AgentConfigService:
"note": target.agent_soul.config_note,
}
@staticmethod
def _skill_items_for_target(target: AgentConfigTarget) -> list[dict[str, object]]:
items = [AgentConfigService._serialize_skill_item(skill) for skill in target.agent_soul.config_skills]
seen_names = {str(item["name"]) for item in items}
for item in SkillManagementService().list_runtime_agent_skills(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
):
if item["name"] in seen_names:
continue
seen_names.add(str(item["name"]))
items.append(item)
return items
@staticmethod
def _config_version_payload(target: AgentConfigTarget) -> dict[str, object]:
return {
+9 -48
View File
@@ -1,8 +1,6 @@
import logging
from collections.abc import Mapping
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from pydantic import BaseModel, ConfigDict, Field
from configs import dify_config
from constants.dsl_version import CURRENT_APP_DSL_VERSION
@@ -12,8 +10,6 @@ from enums.hosted_provider import HostedTrialProvider
from services.billing_service import BillingInfo, BillingService
from services.enterprise.enterprise_service import EnterpriseService
logger = logging.getLogger(__name__)
class FeatureResponseModel(BaseModel):
model_config = ConfigDict(json_schema_serialization_defaults_required=True, protected_namespaces=())
@@ -135,13 +131,6 @@ class PluginInstallationPermissionModel(FeatureResponseModel):
restrict_to_marketplace_only: bool = False
class _EnterprisePluginInstallationPermission(BaseModel):
model_config = ConfigDict(extra="ignore")
plugin_installation_scope: PluginInstallationScope = Field(alias="pluginInstallationScope")
restrict_to_marketplace_only: bool = Field(alias="restrictToMarketplaceOnly", strict=True)
class FeatureModel(FeatureResponseModel):
billing: BillingModel = BillingModel()
education: EducationModel = EducationModel()
@@ -296,14 +285,6 @@ class FeatureService:
"""Return whether Enterprise plugin credential policies must be enforced."""
return dify_config.ENTERPRISE_ENABLED
@classmethod
def get_plugin_installation_permission(cls) -> PluginInstallationPermissionModel:
"""Resolve the validated deployment-wide plugin installation policy."""
if not dify_config.ENTERPRISE_ENABLED:
return PluginInstallationPermissionModel()
return cls._resolve_plugin_installation_permission(EnterpriseService.get_info())
@classmethod
def get_license(cls) -> LicenseModel:
"""Return full license detail. Enterprise-only; requires an authenticated caller.
@@ -471,33 +452,6 @@ class FeatureService:
)
return license_model
@classmethod
def _resolve_plugin_installation_permission(
cls, enterprise_info: Mapping[str, object]
) -> PluginInstallationPermissionModel:
if "PluginInstallationPermission" not in enterprise_info:
return PluginInstallationPermissionModel()
try:
permission = _EnterprisePluginInstallationPermission.model_validate(
enterprise_info["PluginInstallationPermission"]
)
except ValidationError as exc:
# Do not attach the exception because it may contain raw Enterprise configuration values.
logger.error( # noqa: TRY400
"Invalid Enterprise plugin installation permission; denying all plugin installations: %s",
exc.errors(include_input=False),
)
return PluginInstallationPermissionModel(
plugin_installation_scope=PluginInstallationScope.NONE,
restrict_to_marketplace_only=True,
)
return PluginInstallationPermissionModel(
plugin_installation_scope=permission.plugin_installation_scope,
restrict_to_marketplace_only=permission.restrict_to_marketplace_only,
)
@classmethod
def _fulfill_params_from_enterprise(cls, features: SystemFeatureModel):
enterprise_info = EnterpriseService.get_info()
@@ -545,4 +499,11 @@ class FeatureService:
status=LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE))
)
features.plugin_installation_permission = cls._resolve_plugin_installation_permission(enterprise_info)
if "PluginInstallationPermission" in enterprise_info:
plugin_installation_info = enterprise_info["PluginInstallationPermission"]
features.plugin_installation_permission.plugin_installation_scope = plugin_installation_info[
"pluginInstallationScope"
]
features.plugin_installation_permission.restrict_to_marketplace_only = plugin_installation_info[
"restrictToMarketplaceOnly"
]
@@ -0,0 +1,348 @@
"""Providers for delivering human input forms through configured channels.
Dispatch has two explicit phases: load immutable DTOs while a SQLAlchemy session
is active, then perform provider I/O after the session context has closed.
Providers must not depend on live ORM instances because email and future IM
clients can block or retry independently from database transaction lifetimes.
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Protocol
from pydantic import TypeAdapter, ValidationError
from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from core.workflow.human_input_adapter import (
DeliveryMethodType,
EmailDeliveryConfig,
EmailDeliveryMethod,
InstantMessageDeliveryMethod,
)
from extensions.ext_mail import mail
from graphon.runtime import VariablePool
from models.human_input import (
EmailExternalRecipientPayload,
EmailMemberRecipientPayload,
HumanInputDelivery,
HumanInputForm,
HumanInputFormRecipient,
InstantMessageRecipientPayload,
RecipientPayload,
RecipientType,
)
logger = logging.getLogger(__name__)
_RECIPIENT_PAYLOAD_ADAPTER = TypeAdapter(RecipientPayload)
@dataclass(frozen=True)
class HumanInputFormDeliveryRecipientContext:
recipient_id: str
recipient_type: RecipientType
recipient_payload: str
access_token: str | None
@dataclass(frozen=True)
class HumanInputFormDeliveryContext:
form_id: str
tenant_id: str
app_id: str
workflow_run_id: str | None
rendered_content: str
delivery_id: str
delivery_method_type: DeliveryMethodType
delivery_config_id: str | None
channel_payload: str
recipients: tuple[HumanInputFormDeliveryRecipientContext, ...]
variable_pool: VariablePool | None = None
class HumanInputFormDeliveryProvider(Protocol):
"""Sends a persisted human input form through one delivery method."""
delivery_method_type: DeliveryMethodType
def send(self, *, context: HumanInputFormDeliveryContext) -> None: ...
class HumanInputFormMailClient(Protocol):
def send(self, *, to: str, subject: str, html: str) -> None: ...
class HumanInputFormDeliveryProviderRegistry:
_providers: dict[DeliveryMethodType, HumanInputFormDeliveryProvider]
def __init__(self, providers: Sequence[HumanInputFormDeliveryProvider] | None = None) -> None:
self._providers: dict[DeliveryMethodType, HumanInputFormDeliveryProvider] = {}
for provider in providers or ():
self.register(provider)
def register(self, provider: HumanInputFormDeliveryProvider) -> None:
self._providers[provider.delivery_method_type] = provider
def dispatch(self, *, context: HumanInputFormDeliveryContext, continue_on_error: bool = True) -> bool:
"""Dispatch one loaded context through its provider.
Provider calls are external I/O, so callers default to isolating send failures per context.
Loading and DTO construction stay outside this boundary and should fail through their caller.
"""
provider = self._providers.get(context.delivery_method_type)
if provider is None:
logger.warning(
"No human input form delivery provider registered, form_id=%s, delivery_id=%s, method=%s",
context.form_id,
context.delivery_id,
context.delivery_method_type,
)
return False
try:
provider.send(context=context)
except Exception:
logger.exception(
"Failed to dispatch human input form delivery, form_id=%s, delivery_id=%s, method=%s",
context.form_id,
context.delivery_id,
context.delivery_method_type,
)
if not continue_on_error:
raise
return False
return True
@classmethod
def default(cls, *, mail_client: HumanInputFormMailClient = mail) -> HumanInputFormDeliveryProviderRegistry:
return cls(
[
EmailHumanInputFormDeliveryProvider(mail_client=mail_client),
UnsupportedInstantMessageHumanInputFormDeliveryProvider(),
]
)
class HumanInputFormDeliveryDispatcher:
_registry: HumanInputFormDeliveryProviderRegistry
def __init__(self, registry: HumanInputFormDeliveryProviderRegistry | None = None) -> None:
self._registry = registry or HumanInputFormDeliveryProviderRegistry.default()
def load_form_contexts(
self,
*,
session: Session,
form: HumanInputForm,
variable_pool: VariablePool | None = None,
delivery_method_types: Sequence[DeliveryMethodType] | None = None,
) -> tuple[HumanInputFormDeliveryContext, ...]:
stmt = select(HumanInputDelivery).where(HumanInputDelivery.form_id == form.id)
if delivery_method_types:
stmt = stmt.where(HumanInputDelivery.delivery_method_type.in_(delivery_method_types))
deliveries = session.scalars(stmt).all()
contexts: list[HumanInputFormDeliveryContext] = []
for delivery in deliveries:
recipients = session.scalars(
select(HumanInputFormRecipient).where(HumanInputFormRecipient.delivery_id == delivery.id),
).all()
contexts.append(
_build_delivery_context(
form=form,
delivery=delivery,
recipients=recipients,
variable_pool=variable_pool,
)
)
return tuple(contexts)
def dispatch_contexts(
self,
contexts: Sequence[HumanInputFormDeliveryContext],
*,
continue_on_error: bool = True,
) -> None:
for context in contexts:
self._registry.dispatch(context=context, continue_on_error=continue_on_error)
def _build_delivery_context(
*,
form: HumanInputForm,
delivery: HumanInputDelivery,
recipients: Sequence[HumanInputFormRecipient],
variable_pool: VariablePool | None,
) -> HumanInputFormDeliveryContext:
return HumanInputFormDeliveryContext(
form_id=form.id,
tenant_id=form.tenant_id,
app_id=form.app_id,
workflow_run_id=form.workflow_run_id,
rendered_content=form.rendered_content,
delivery_id=delivery.id,
delivery_method_type=delivery.delivery_method_type,
delivery_config_id=delivery.delivery_config_id,
channel_payload=delivery.channel_payload,
recipients=tuple(
HumanInputFormDeliveryRecipientContext(
recipient_id=recipient.id,
recipient_type=recipient.recipient_type,
recipient_payload=recipient.recipient_payload,
access_token=recipient.access_token,
)
for recipient in recipients
),
variable_pool=variable_pool,
)
@dataclass(frozen=True)
class _EmailRecipient:
email: str
token: str
class EmailHumanInputFormDeliveryProvider:
_mail: HumanInputFormMailClient
delivery_method_type = DeliveryMethodType.EMAIL
def __init__(self, *, mail_client: HumanInputFormMailClient = mail) -> None:
self._mail = mail_client
def send(self, *, context: HumanInputFormDeliveryContext) -> None:
try:
delivery_method = EmailDeliveryMethod.model_validate_json(context.channel_payload)
except ValidationError:
# Persisted payloads can be stale or manually corrupted; skip only this delivery.
logger.warning(
"Invalid human input email delivery payload, form_id=%s, delivery_id=%s",
context.form_id,
context.delivery_id,
exc_info=True,
)
return
recipients = self._resolve_recipients(context.recipients)
if not recipients:
return
subject = EmailDeliveryConfig.sanitize_subject(delivery_method.config.subject)
for recipient in recipients:
form_link = _build_form_link(recipient.token)
body = _render_body(
delivery_method.config.body,
form_link,
variable_pool=context.variable_pool,
)
self._mail.send(
to=recipient.email,
subject=subject,
html=body,
)
@staticmethod
def _resolve_recipients(recipients: Sequence[HumanInputFormDeliveryRecipientContext]) -> list[_EmailRecipient]:
resolved: list[_EmailRecipient] = []
for recipient in recipients:
payload = _parse_email_recipient_payload(recipient)
if payload is None:
continue
token = recipient.access_token
if not token:
continue
resolved.append(_EmailRecipient(email=payload.email, token=token))
return resolved
class UnsupportedInstantMessageHumanInputFormDeliveryProvider:
delivery_method_type = DeliveryMethodType.IM
def send(self, *, context: HumanInputFormDeliveryContext) -> None:
try:
InstantMessageDeliveryMethod.model_validate_json(context.channel_payload)
except ValidationError:
logger.warning(
"Invalid human input instant message delivery payload, form_id=%s, delivery_id=%s",
context.form_id,
context.delivery_id,
exc_info=True,
)
return
valid_recipients = 0
for recipient in context.recipients:
if _parse_instant_message_recipient_payload(recipient) is not None:
valid_recipients += 1
logger.warning(
"Human input instant message delivery is not implemented, form_id=%s, delivery_id=%s, recipients=%s",
context.form_id,
context.delivery_id,
valid_recipients,
)
def _build_form_link(token: str) -> str:
base_url = dify_config.APP_WEB_URL
return f"{base_url.rstrip('/')}/form/{token}"
def _parse_email_recipient_payload(
recipient: HumanInputFormDeliveryRecipientContext,
) -> EmailMemberRecipientPayload | EmailExternalRecipientPayload | None:
try:
payload = _RECIPIENT_PAYLOAD_ADAPTER.validate_json(recipient.recipient_payload)
except ValidationError:
# Recipient payloads are persisted per delivery; a bad row should not block other recipients.
logger.warning(
"Invalid human input recipient payload, recipient_id=%s",
recipient.recipient_id,
exc_info=True,
)
return None
match payload:
case EmailMemberRecipientPayload() | EmailExternalRecipientPayload():
if payload.email:
return payload
return None
case _:
return None
def _parse_instant_message_recipient_payload(
recipient: HumanInputFormDeliveryRecipientContext,
) -> InstantMessageRecipientPayload | None:
try:
payload = _RECIPIENT_PAYLOAD_ADAPTER.validate_json(recipient.recipient_payload)
except ValidationError:
logger.warning(
"Invalid human input instant message recipient payload, recipient_id=%s",
recipient.recipient_id,
exc_info=True,
)
return None
if isinstance(payload, InstantMessageRecipientPayload):
return payload
return None
def _render_body(
body_template: str,
form_link: str,
*,
variable_pool: VariablePool | None,
) -> str:
body = EmailDeliveryConfig.render_body_template(
body=body_template,
url=form_link,
variable_pool=variable_pool,
)
return EmailDeliveryConfig.render_markdown_body(body)
+1
View File
@@ -0,0 +1 @@
"""Application services for Human Input v2 use cases."""
+239
View File
@@ -0,0 +1,239 @@
"""Application orchestration for authorized Human Input v2 submissions.
The handler keeps authorization and persistence behind their deep domain and
repository interfaces. Its only application-layer policy is the post-commit
boundary: a winning runtime submission is committed before workflow resume is
requested, and a known enqueue failure never changes the persisted outcome.
Retryable transaction conflicts restart the complete use case with a fresh
snapshot; partial authorization or persistence steps are never retried alone.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from enum import StrEnum
from typing import Protocol
from core.human_input_v2.approval import (
AuthorizedSubmissionCommit,
FormAuthorizationAuditEvent,
FormAuthorizationAuditEventType,
FormSubmission,
FrozenJSONObject,
HumanInputForm,
RetryableSubmissionPersistenceError,
SubmissionAttemptScope,
SubmissionAuthorizationRejection,
SubmissionAuthorizer,
SubmissionCommitStatus,
SubmissionRepository,
)
from core.human_input_v2.entities import HumanInputV2FormKind
from core.human_input_v2.shared import AuditEventId, FormId, SubmissionId, UtcTimestamp, WorkspaceId
logger = logging.getLogger(__name__)
_MAX_SUBMISSION_TRANSACTION_RETRIES = 1
@dataclass(frozen=True, slots=True)
class WorkflowResumeIdentity:
"""Stable workflow owner identity used by an idempotent resume adapter."""
workspace_id: WorkspaceId
form_id: FormId
workflow_pause_id: str
node_execution_id: str
def __post_init__(self) -> None:
if not self.workflow_pause_id.strip() or not self.node_execution_id.strip():
raise ValueError("workflow resume identity values must not be blank")
class WorkflowResumeEnqueueError(RuntimeError):
"""A resume adapter could not accept an idempotent enqueue request."""
class WorkflowResumePort(Protocol):
"""Enqueue workflow resume once for a stable form/workflow identity."""
def enqueue_once(self, identity: WorkflowResumeIdentity) -> None: ...
@dataclass(frozen=True, slots=True)
class SubmitFormCommand:
"""Verified proof and caller-owned record identities for one submission attempt."""
scope: SubmissionAttemptScope
proof: object
selected_action_id: str
input_snapshot: FrozenJSONObject
canonical_values: FrozenJSONObject
submission_id: SubmissionId
authorization_audit_event_id: AuditEventId
rejection_audit_event_id: AuditEventId
resume_identity: WorkflowResumeIdentity | None
now: UtcTimestamp
class SubmitFormResultStatus(StrEnum):
"""Stable application outcomes independent of transport status codes."""
SUBMITTED = "submitted"
ALREADY_COMPLETED = "already_completed"
REJECTED = "rejected"
@dataclass(frozen=True, slots=True)
class SubmitFormResult:
"""Submission outcome plus post-commit resume delivery state."""
status: SubmitFormResultStatus
submission: FormSubmission | None
rejection: SubmissionAuthorizationRejection | None
resume_enqueued: bool
class SubmitHumanInputFormHandler:
"""Authorize and persist once, retrying only complete transient transactions."""
def __init__(self, repository: SubmissionRepository, resume_port: WorkflowResumePort) -> None:
self._repository = repository
self._resume_port = resume_port
def handle(self, command: SubmitFormCommand) -> SubmitFormResult:
"""Return a stable outcome while preserving commit-before-enqueue ordering."""
identity = self._prevalidate_resume_identity(command)
transaction_result = self._handle_transaction_with_retry(command, identity)
if transaction_result.status is not SubmitFormResultStatus.SUBMITTED:
return transaction_result
try:
self._resume_port.enqueue_once(identity)
except WorkflowResumeEnqueueError:
logger.exception(
"Failed to enqueue Human Input v2 workflow resume after submission commit: "
"workspace_id=%s form_id=%s workflow_pause_id=%s node_execution_id=%s",
identity.workspace_id,
identity.form_id,
identity.workflow_pause_id,
identity.node_execution_id,
)
return SubmitFormResult(
SubmitFormResultStatus.SUBMITTED,
transaction_result.submission,
None,
False,
)
return SubmitFormResult(
SubmitFormResultStatus.SUBMITTED,
transaction_result.submission,
None,
True,
)
def _handle_transaction_with_retry(
self,
command: SubmitFormCommand,
identity: WorkflowResumeIdentity,
) -> SubmitFormResult:
for retry_count in range(_MAX_SUBMISSION_TRANSACTION_RETRIES + 1):
try:
return self._handle_transaction_once(command, identity)
except RetryableSubmissionPersistenceError:
if retry_count == _MAX_SUBMISSION_TRANSACTION_RETRIES:
raise
logger.warning(
"Retrying Human Input v2 submission after transaction serialization failure: "
"workspace_id=%s form_id=%s retry_count=%s",
identity.workspace_id,
identity.form_id,
retry_count + 1,
)
raise AssertionError("submission transaction retry loop must return or raise")
def _handle_transaction_once(
self,
command: SubmitFormCommand,
identity: WorkflowResumeIdentity,
) -> SubmitFormResult:
"""Run one complete load, authorization, Form decision, and commit attempt."""
with self._repository.transaction(command.scope) as transaction:
context = transaction.load_authorization_context(proof=command.proof)
self._validate_runtime_form_identity(context.form, identity)
decision = SubmissionAuthorizer.authorize(
context=context,
proof=command.proof,
selected_action_id=command.selected_action_id,
now=command.now,
)
if decision.rejection is not None:
rejection = decision.rejection
if rejection is SubmissionAuthorizationRejection.FORM_ALREADY_SUBMITTED:
return SubmitFormResult(SubmitFormResultStatus.ALREADY_COMPLETED, None, None, False)
transaction.append_rejection_audit(
FormAuthorizationAuditEvent(
id=command.rejection_audit_event_id,
event_type=FormAuthorizationAuditEventType.SUBMISSION_REJECTED,
form_ref=command.scope.form_ref,
approver_grant_id=command.scope.approver_grant_id,
endpoint_id=command.scope.endpoint_id,
channel=context.endpoint.channel if context.endpoint is not None else None,
reason_code=rejection,
reason_message=None,
authorization_proof=None,
payload=FrozenJSONObject.from_mapping({"selected_action_id": command.selected_action_id}),
occurred_at=command.now,
created_at=command.now,
updated_at=command.now,
)
)
return SubmitFormResult(SubmitFormResultStatus.REJECTED, None, rejection, False)
authorized = decision.authorized
assert authorized is not None
commit_result = transaction.commit_authorized_submission_once(
AuthorizedSubmissionCommit(
submission_id=command.submission_id,
authorization_audit_event_id=command.authorization_audit_event_id,
authorized=authorized,
input_snapshot=command.input_snapshot,
canonical_values=command.canonical_values,
)
)
if commit_result.status is SubmissionCommitStatus.ALREADY_COMPLETED:
return SubmitFormResult(SubmitFormResultStatus.ALREADY_COMPLETED, None, None, False)
return SubmitFormResult(SubmitFormResultStatus.SUBMITTED, commit_result.submission, None, False)
@staticmethod
def _prevalidate_resume_identity(command: SubmitFormCommand) -> WorkflowResumeIdentity:
identity = command.resume_identity
if identity is None:
raise ValueError("runtime resume identity is required before submission persistence")
if (
identity.workspace_id != command.scope.form_ref.workspace_id
or identity.form_id != command.scope.form_ref.form_id
):
raise ValueError("runtime resume identity does not match the submission form owner")
return identity
@staticmethod
def _validate_runtime_form_identity(form: HumanInputForm, identity: WorkflowResumeIdentity) -> None:
if form.kind is not HumanInputV2FormKind.RUNTIME:
raise ValueError("submission handler accepts runtime forms only")
if form.workflow_pause_id != identity.workflow_pause_id or form.node_execution_id != identity.node_execution_id:
raise ValueError("runtime resume identity does not match the loaded form")
__all__ = [
"SubmitFormCommand",
"SubmitFormResult",
"SubmitFormResultStatus",
"SubmitHumanInputFormHandler",
"WorkflowResumeEnqueueError",
"WorkflowResumeIdentity",
"WorkflowResumePort",
]
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More