Compare commits
139
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c8cdb4ca1 | ||
|
|
dfa2859ea3 | ||
|
|
a68fca6f68 | ||
|
|
f7a14cb99f | ||
|
|
02f9e1b7ad | ||
|
|
5350700e47 | ||
|
|
67a31db615 | ||
|
|
ec3ff5cc01 | ||
|
|
7c2c319538 | ||
|
|
3815aac382 | ||
|
|
4136705d97 | ||
|
|
14b740fc60 | ||
|
|
763d1695f2 | ||
|
|
bcefa8491d | ||
|
|
f89bcd0496 | ||
|
|
08322dc8c2 | ||
|
|
fab7a0af7b | ||
|
|
ce6a3787de | ||
|
|
a277a0dc4c | ||
|
|
f87b1f4693 | ||
|
|
b3475ed624 | ||
|
|
92c3600fc4 | ||
|
|
363a7834aa | ||
|
|
be54c54a5d | ||
|
|
6f3d09009d | ||
|
|
d6583084ec | ||
|
|
993ccfc7d8 | ||
|
|
8abbdec4fd | ||
|
|
64dba42bf3 | ||
|
|
a3ded70600 | ||
|
|
fdde47bbc3 | ||
|
|
c16bbe786f | ||
|
|
ab816a54aa | ||
|
|
2d3ba7fa5d | ||
|
|
7c5fc2a133 | ||
|
|
cb9ae0c0a6 | ||
|
|
ad31e11320 | ||
|
|
eedb4d29ea | ||
|
|
4639e4b818 | ||
|
|
95e73dd4b1 | ||
|
|
05e2f1ddef | ||
|
|
4ef0c50a97 | ||
|
|
6c2ac9cd91 | ||
|
|
318fbcef27 | ||
|
|
4db897abc1 | ||
|
|
5f78012b7d | ||
|
|
eed57c9abd | ||
|
|
179351d1fe | ||
|
|
f1f20384eb | ||
|
|
4b2ceb1bf6 | ||
|
|
eda9315a7b | ||
|
|
3a848a7a9c | ||
|
|
11b0723a92 | ||
|
|
f6440f6af4 | ||
|
|
462af76989 | ||
|
|
4b17446ed6 | ||
|
|
252054f9b6 | ||
|
|
4193deb220 | ||
|
|
075d44a7fa | ||
|
|
5e696c9131 | ||
|
|
5f1646c14b | ||
|
|
30b04d4d14 | ||
|
|
e592c9f3ac | ||
|
|
fa5a2ff899 | ||
|
|
45d6efd8ab | ||
|
|
d6b8466bcd | ||
|
|
7fc5e60007 | ||
|
|
4e4f34be1f | ||
|
|
09076f6555 | ||
|
|
dfcf690d9a | ||
|
|
ad1fe871f4 | ||
|
|
f587807123 | ||
|
|
6154e58da7 | ||
|
|
85d3226175 | ||
|
|
47f20c8f3d | ||
|
|
074c172f52 | ||
|
|
9986ff92b6 | ||
|
|
ce694a20b4 | ||
|
|
ef5933a90e | ||
|
|
ce66f7505c | ||
|
|
41024954b7 | ||
|
|
1af6a1803a | ||
|
|
446f0f993a | ||
|
|
3c88536e2a | ||
|
|
34b71467e7 | ||
|
|
7f2e40225c | ||
|
|
33b0bc578f | ||
|
|
aa128184f8 | ||
|
|
47f6cac432 | ||
|
|
1c9d15116c | ||
|
|
679d011d17 | ||
|
|
1d4b5eba35 | ||
|
|
ad328d156a | ||
|
|
1e5c206d31 | ||
|
|
8b66eedaba | ||
|
|
ea7465b853 | ||
|
|
c5d19039c4 | ||
|
|
9cc072ed5d | ||
|
|
0c3e3f6f78 | ||
|
|
922565f5e3 | ||
|
|
90487f57fc | ||
|
|
cee50acbc4 | ||
|
|
84fd4011ab | ||
|
|
ef5f698746 | ||
|
|
2603ca1bfb | ||
|
|
9791c0891d | ||
|
|
93a745c9ca | ||
|
|
78980e9f1f | ||
|
|
b30c3578a7 | ||
|
|
ef5c3df88b | ||
|
|
e35752ddbc | ||
|
|
5b5d62a2af | ||
|
|
cdd111869b | ||
|
|
4fd6c028af | ||
|
|
abcc77204b | ||
|
|
dce8e971be | ||
|
|
2249974daa | ||
|
|
f49e3dd5b1 | ||
|
|
51c471a359 | ||
|
|
1f05bdd543 | ||
|
|
2250f83d0f | ||
|
|
30fdbed7f2 | ||
|
|
3ff1c91d84 | ||
|
|
b9561cef58 | ||
|
|
ee1b9cb95f | ||
|
|
c1154686d8 | ||
|
|
3ead32a4e8 | ||
|
|
6f1ecc47e8 | ||
|
|
3d80e13f4f | ||
|
|
75e2bcd460 | ||
|
|
691eca212b | ||
|
|
e21a0883c1 | ||
|
|
ba1c13e37c | ||
|
|
1e2f6f234e | ||
|
|
6813e8d34f | ||
|
|
e348d8accf | ||
|
|
b71bbc2d3b | ||
|
|
69e18efab6 | ||
|
|
196287fc8a |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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.")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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."""
|
||||
...
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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.")
|
||||
@@ -0,0 +1,5 @@
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
Timestamp = Annotated[int, Field(..., description="Unix timestamp in milliseconds")]
|
||||
+1
-5
@@ -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")
|
||||
|
||||
-114
@@ -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")
|
||||
+23
-110
@@ -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")
|
||||
|
||||
-21
@@ -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
|
||||
+141
@@ -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")
|
||||
+320
@@ -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")
|
||||
+105
@@ -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")
|
||||
+183
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -249,7 +249,6 @@ class TagType(StrEnum):
|
||||
KNOWLEDGE = "knowledge"
|
||||
APP = "app"
|
||||
SNIPPET = "snippet"
|
||||
SKILL = "skill"
|
||||
|
||||
|
||||
class DatasetMetadataType(StrEnum):
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+12
-21
@@ -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"
|
||||
|
||||
|
||||
+29
-67
@@ -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
@@ -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",
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Application services for Human Input v2 use cases."""
|
||||
@@ -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
Reference in New Issue
Block a user