mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 22:02:23 +08:00
feat(hitl): improve forms, notifications, and IM contact sync (#45945)
Co-authored-by: zxhlyh <jasonapring2015@outlook.com>
This commit is contained in:
1 parent
116a990765
commit
a4d64bcddd
91 files changed
+2590
-352
No files matched your search
@@ -134,6 +134,7 @@ class HumanInputContactSummary(BaseModel):
|
||||
|
||||
id: ContactId = Field(description="Unique contact identifier.")
|
||||
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.")
|
||||
created_at: Timestamp = Field(description="Timestamp when the contact was created.")
|
||||
|
||||
@@ -279,12 +280,15 @@ class IMSyncRunResultCounts(ResponseModel):
|
||||
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.
|
||||
The latest-only UI displays ``finished_at`` as the explicit sync time.
|
||||
The initiator display name is resolved from the initiating Dify Account.
|
||||
"""
|
||||
|
||||
id: IMSyncRunId = Field(description="Unique sync run identifier.")
|
||||
status: IMSyncRunStatus = Field(description="Current lifecycle state of the sync run.")
|
||||
initiator_display_name: str = Field(
|
||||
description="Current initiating Account name; empty for EE administrators or an unavailable Account."
|
||||
)
|
||||
started_at: Timestamp | None = Field(
|
||||
default=None, description="Unix timestamp in seconds when the sync run started."
|
||||
)
|
||||
|
||||
@@ -76,10 +76,10 @@ from core.human_input_v2.im_integration import (
|
||||
ContactIMBindingView,
|
||||
IMBindingCommandError,
|
||||
IMBindingCommandErrorCode,
|
||||
IMSyncRun,
|
||||
SyncContactSnapshot,
|
||||
SyncResultFact,
|
||||
)
|
||||
from core.human_input_v2.im_integration.sync_records import AccountIMSyncInitiator
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
@@ -123,6 +123,7 @@ from services.human_input_v2.im_contact_sync.service import (
|
||||
IMSyncDispatchUnavailableError,
|
||||
IMSyncRevisionChangedError,
|
||||
IMSyncRunNotFoundError,
|
||||
IMSyncRunSummary,
|
||||
)
|
||||
from services.human_input_v2.node_data_migration import MigrationNode, NodeDataMigrationFailure
|
||||
|
||||
@@ -237,10 +238,13 @@ def _im_application_error_response(error: _IMApplicationError) -> tuple[dict[str
|
||||
)
|
||||
|
||||
|
||||
def _sync_run_payload(run: IMSyncRun) -> dict[str, object]:
|
||||
def _sync_run_payload(summary: IMSyncRunSummary) -> dict[str, object]:
|
||||
"""Serialize a persisted run with the currently resolved Account name."""
|
||||
run = summary.run
|
||||
return {
|
||||
"id": run.id,
|
||||
"status": run.status,
|
||||
"initiator_display_name": summary.initiator_display_name,
|
||||
"started_at": run.started_at,
|
||||
"finished_at": run.finished_at,
|
||||
"error_message": run.error_message,
|
||||
@@ -272,11 +276,13 @@ def _avatar_url(avatar_file_id: str | None) -> str:
|
||||
|
||||
|
||||
def _sync_contact_payload(contact: SyncContactSnapshot, fallback_created_at) -> dict[str, object]:
|
||||
"""Serialize the frozen Contact profile without consulting current state."""
|
||||
# Historical result snapshots predate the Contact creation-time field.
|
||||
created_at = contact.created_at if contact.created_at is not None else fallback_created_at
|
||||
return {
|
||||
"id": contact.contact_id,
|
||||
"name": contact.name,
|
||||
"email": contact.email,
|
||||
"avatar_url": _avatar_url(contact.avatar_file_id),
|
||||
"created_at": created_at,
|
||||
}
|
||||
@@ -369,10 +375,12 @@ def _contact_option_payload(view: ContactWithIMBindings) -> dict[str, object]:
|
||||
|
||||
|
||||
def _contact_summary_payload(view: ContactWithIMBindings) -> dict[str, object]:
|
||||
"""Project one resolved Contact, including its available primary email."""
|
||||
contact = view.contact
|
||||
return {
|
||||
"id": contact.id,
|
||||
"name": contact.name,
|
||||
"email": contact.email,
|
||||
"avatar_url": _contact_avatar_url(contact),
|
||||
"created_at": contact.created_at,
|
||||
}
|
||||
@@ -655,10 +663,11 @@ class WorkspaceIMSyncRunsApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account):
|
||||
"""Start or reuse a workspace run using the authenticated Account ID."""
|
||||
try:
|
||||
run = build_im_contact_sync_application().sync_service.create_or_get_active_run(
|
||||
_workspace_scope(tenant_id),
|
||||
AccountId(current_user.id),
|
||||
AccountIMSyncInitiator(AccountId(current_user.id)),
|
||||
)
|
||||
except (
|
||||
IMChannelNotConfiguredError,
|
||||
@@ -675,7 +684,7 @@ 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."
|
||||
"initiator_display_name is resolved from the initiating Account."
|
||||
)
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[GetLatestIMSyncRunResponse.__name__])
|
||||
|
||||
@@ -4,7 +4,7 @@ import contextvars
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from collections.abc import Generator, Mapping
|
||||
from collections.abc import Callable, Generator, Mapping
|
||||
from typing import TYPE_CHECKING, Any, Literal, overload
|
||||
|
||||
from flask import Flask, current_app
|
||||
@@ -29,6 +29,7 @@ from core.app.apps.advanced_chat.generate_task_pipeline import (
|
||||
MessageSnapshot,
|
||||
WorkflowSnapshot,
|
||||
)
|
||||
from core.app.apps.base_app_generator import noop_restore_file_access
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
|
||||
from core.app.apps.draft_variable_saver import DraftVariableSaverFactory
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
@@ -291,10 +292,15 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
|
||||
graph_runtime_state: RuntimeState,
|
||||
pause_state_config: PauseStateLayerConfig | None = None,
|
||||
response_stream_filter: ResponseStreamFilter | None = None,
|
||||
restore_file_access: Callable[[], None] = noop_restore_file_access,
|
||||
):
|
||||
"""
|
||||
Resume a paused advanced chat execution.
|
||||
|
||||
``restore_file_access`` restores host-owned grants inside the new
|
||||
execution scope before worker contexts are copied. The default callback
|
||||
leaves existing access rules unchanged.
|
||||
|
||||
``trace_scope`` is transient and excluded from generate-entity serialization,
|
||||
so resumed executions create it again before persistence layers receive the entity.
|
||||
"""
|
||||
@@ -324,6 +330,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
|
||||
pause_state_config=pause_state_config,
|
||||
graph_runtime_state=graph_runtime_state,
|
||||
response_stream_filter=response_stream_filter,
|
||||
restore_file_access=restore_file_access,
|
||||
session=session,
|
||||
)
|
||||
|
||||
@@ -532,10 +539,14 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
|
||||
pause_state_config: PauseStateLayerConfig | None = None,
|
||||
graph_runtime_state: RuntimeState | None = None,
|
||||
response_stream_filter: ResponseStreamFilter | None = None,
|
||||
restore_file_access: Callable[[], None] = noop_restore_file_access,
|
||||
) -> Mapping[str, Any] | Generator[str | Mapping[str, Any]]:
|
||||
"""
|
||||
Generate App response.
|
||||
|
||||
On resume, HITL submissions must already be committed. Restore their
|
||||
EndUser file grants before capturing worker and response contexts.
|
||||
|
||||
:param workflow: Workflow
|
||||
:param user: account or end user
|
||||
:param invoke_from: invoke from source
|
||||
@@ -552,6 +563,8 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
|
||||
invoke_from=invoke_from,
|
||||
):
|
||||
grant_file_default_access(application_generate_entity.app_config.variables)
|
||||
# Restore grants before copying contexts for both workers and responses.
|
||||
restore_file_access()
|
||||
is_first_conversation = conversation is None
|
||||
|
||||
if conversation is not None and message is not None:
|
||||
|
||||
@@ -36,6 +36,10 @@ logger = logging.getLogger(__name__)
|
||||
_WORKER_THREAD_JOIN_TIMEOUT_SECONDS = 300
|
||||
|
||||
|
||||
def noop_restore_file_access() -> None:
|
||||
"""Leave the current file-access scope unchanged when no restoration is needed."""
|
||||
|
||||
|
||||
@final
|
||||
class _DebuggerDraftVariableSaver:
|
||||
"""Adapter that binds SQLAlchemy session setup outside the saver port."""
|
||||
|
||||
@@ -4,7 +4,7 @@ import contextvars
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from collections.abc import Callable, Generator, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Literal, overload
|
||||
|
||||
from flask import Flask, current_app
|
||||
@@ -15,7 +15,7 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
import contexts
|
||||
from configs import dify_config
|
||||
from core.app.app_config.features.file_upload.manager import FileUploadConfigManager
|
||||
from core.app.apps.base_app_generator import BaseAppGenerator
|
||||
from core.app.apps.base_app_generator import BaseAppGenerator, noop_restore_file_access
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
|
||||
from core.app.apps.draft_variable_saver import DraftVariableSaverFactory
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
@@ -300,10 +300,15 @@ class WorkflowAppGenerator(BaseAppGenerator):
|
||||
pause_state_config: PauseStateLayerConfig | None = None,
|
||||
variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER,
|
||||
response_stream_filter: ResponseStreamFilter | None = None,
|
||||
restore_file_access: Callable[[], None] = noop_restore_file_access,
|
||||
) -> Mapping[str, Any] | Generator[str | Mapping[str, Any]]:
|
||||
"""
|
||||
Resume a paused workflow execution using the persisted runtime state.
|
||||
|
||||
``restore_file_access`` restores host-owned grants inside the new
|
||||
execution scope before worker contexts are copied. The default callback
|
||||
leaves existing access rules unchanged.
|
||||
|
||||
``trace_scope`` is transient and excluded from generate-entity serialization,
|
||||
so resumed executions create it again before persistence layers receive the entity.
|
||||
"""
|
||||
@@ -334,6 +339,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
|
||||
graph_runtime_state=graph_runtime_state,
|
||||
pause_state_config=pause_state_config,
|
||||
response_stream_filter=response_stream_filter,
|
||||
restore_file_access=restore_file_access,
|
||||
)
|
||||
|
||||
def _generate(
|
||||
@@ -353,10 +359,14 @@ class WorkflowAppGenerator(BaseAppGenerator):
|
||||
graph_runtime_state: RuntimeState | None = None,
|
||||
pause_state_config: PauseStateLayerConfig | None = None,
|
||||
response_stream_filter: ResponseStreamFilter | None = None,
|
||||
restore_file_access: Callable[[], None] = noop_restore_file_access,
|
||||
) -> Mapping[str, Any] | Generator[str | Mapping[str, Any]]:
|
||||
"""
|
||||
Generate App response.
|
||||
|
||||
On resume, HITL submissions must already be committed. Restore their
|
||||
EndUser file grants before capturing worker and response contexts.
|
||||
|
||||
:param app_model: App
|
||||
:param workflow: Workflow
|
||||
:param user: account or end user
|
||||
@@ -372,6 +382,8 @@ class WorkflowAppGenerator(BaseAppGenerator):
|
||||
invoke_from=invoke_from,
|
||||
):
|
||||
grant_file_default_access(application_generate_entity.app_config.variables)
|
||||
# Restore grants before copying contexts for both workers and responses.
|
||||
restore_file_access()
|
||||
# init queue manager
|
||||
queue_manager = WorkflowAppQueueManager(
|
||||
task_id=application_generate_entity.task_id,
|
||||
|
||||
@@ -13,7 +13,6 @@ from pydantic import NaiveDatetime
|
||||
|
||||
from core.human_input_v2.entities import IMProvider, IMSyncResultType
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
DirectoryScope,
|
||||
IMIdentityId,
|
||||
@@ -24,6 +23,7 @@ from repositories.human_input_v2.im_channel_repository import IMChannelId
|
||||
from .sync_reconciliation import ReconciliationReasonCode
|
||||
from .sync_records import (
|
||||
IMChannelRevision,
|
||||
IMSyncInitiator,
|
||||
IMSyncRun,
|
||||
StaleRevision,
|
||||
SynchronizedIMIdentityPage,
|
||||
@@ -103,10 +103,10 @@ class IMSyncRepository(Protocol):
|
||||
*,
|
||||
organization_scope: DirectoryScope,
|
||||
sync_run_id: IMSyncRunId,
|
||||
started_by_account_id: AccountId | None,
|
||||
started_by: IMSyncInitiator,
|
||||
now: NaiveDatetime,
|
||||
) -> ActiveRunDecision:
|
||||
"""Lock Channel and return at most one active run."""
|
||||
"""Lock Channel and create or reuse a run, preserving its original initiator."""
|
||||
...
|
||||
|
||||
def load_sync_run(self, sync_run_id: IMSyncRunId) -> IMSyncRun | None:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import NewType
|
||||
|
||||
from pydantic import NaiveDatetime
|
||||
|
||||
@@ -127,9 +128,33 @@ class SynchronizedIMIdentityPage:
|
||||
total: int
|
||||
|
||||
|
||||
SysUserId = NewType("SysUserId", str)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccountIMSyncInitiator:
|
||||
"""A Dify Account that initiated a CE/SaaS synchronization."""
|
||||
|
||||
account_id: AccountId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SysUserIMSyncInitiator:
|
||||
"""An EE administrator identified by the upstream sys_users.id UUID."""
|
||||
|
||||
sys_user_id: SysUserId
|
||||
|
||||
|
||||
type IMSyncInitiator = AccountIMSyncInitiator | SysUserIMSyncInitiator
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMSyncRun:
|
||||
"""Independent sync aggregate that captures one complete Channel revision."""
|
||||
"""Capture one Channel revision and either an Account or EE administrator.
|
||||
|
||||
Every run has exactly one initiating identity, represented by one of the
|
||||
two variants. Missing initiators are not valid synchronization state.
|
||||
"""
|
||||
|
||||
id: IMSyncRunId
|
||||
channel_revision: IMChannelRevision
|
||||
@@ -140,7 +165,7 @@ class IMSyncRun:
|
||||
failed_count: int
|
||||
removed_count: int
|
||||
skipped_count: int
|
||||
started_by_account_id: AccountId | None
|
||||
started_by: IMSyncInitiator
|
||||
started_at: NaiveDatetime | None
|
||||
finished_at: NaiveDatetime | None
|
||||
error_code: str | None
|
||||
@@ -155,9 +180,10 @@ class IMSyncRun:
|
||||
sync_run_id: IMSyncRunId,
|
||||
channel_revision: IMChannelRevision,
|
||||
provider: IMProvider,
|
||||
started_by_account_id: AccountId | None,
|
||||
started_by: IMSyncInitiator,
|
||||
now: NaiveDatetime,
|
||||
) -> IMSyncRun:
|
||||
"""Create a queued run from one trusted initiating identity, without I/O."""
|
||||
return cls(
|
||||
id=sync_run_id,
|
||||
channel_revision=channel_revision,
|
||||
@@ -168,7 +194,7 @@ class IMSyncRun:
|
||||
failed_count=0,
|
||||
removed_count=0,
|
||||
skipped_count=0,
|
||||
started_by_account_id=started_by_account_id,
|
||||
started_by=started_by,
|
||||
started_at=None,
|
||||
finished_at=None,
|
||||
error_code=None,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Add the upstream EE synchronization initiator identity.
|
||||
|
||||
Revision ID: 6b7e2a9c4d10
|
||||
Revises: 53c10126d551
|
||||
Create Date: 2026-11-09 12:00:00
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from models.types import StringUUID
|
||||
|
||||
revision = "6b7e2a9c4d10"
|
||||
down_revision = "53c10126d551"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add the EE UUID and require exactly one initiating identity per run."""
|
||||
with op.batch_alter_table("human_input_im_sync_runs") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"started_by_sys_user_id",
|
||||
StringUUID,
|
||||
nullable=True,
|
||||
comment="Logical reference to upstream EE sys_users.id; mutually exclusive with started_by_account_id.",
|
||||
)
|
||||
)
|
||||
batch_op.create_check_constraint(
|
||||
"initiator_exclusive",
|
||||
"(started_by_account_id IS NOT NULL AND started_by_sys_user_id IS NULL) OR "
|
||||
"(started_by_account_id IS NULL AND started_by_sys_user_id IS NOT NULL)",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove the EE initiator reference while preserving existing Account IDs."""
|
||||
with op.batch_alter_table("human_input_im_sync_runs") as batch_op:
|
||||
batch_op.drop_constraint("initiator_exclusive", type_="check")
|
||||
batch_op.drop_column("started_by_sys_user_id")
|
||||
@@ -768,6 +768,11 @@ class HumanInputIMSyncRun(DefaultFieldsDCMixin, TypeBase):
|
||||
__tablename__ = "human_input_im_sync_runs"
|
||||
__table_args__ = (
|
||||
sa.CheckConstraint("channel_config_version > 0", name="captured_version_positive"),
|
||||
sa.CheckConstraint(
|
||||
"(started_by_account_id IS NOT NULL AND started_by_sys_user_id IS NULL) OR "
|
||||
"(started_by_account_id IS NULL AND started_by_sys_user_id IS NOT NULL)",
|
||||
name="initiator_exclusive",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"added_count >= 0 AND not_matched_count >= 0 AND failed_count >= 0 AND removed_count >= 0 "
|
||||
"AND skipped_count >= 0",
|
||||
@@ -836,7 +841,13 @@ class HumanInputIMSyncRun(DefaultFieldsDCMixin, TypeBase):
|
||||
StringUUID,
|
||||
nullable=True,
|
||||
default=None,
|
||||
comment="Logical foreign key to accounts.id for the administrator who started this run.",
|
||||
comment="Logical accounts.id reference for CE/SaaS initiators; null only for EE sys_user initiators.",
|
||||
)
|
||||
started_by_sys_user_id: Mapped[str | None] = mapped_column(
|
||||
StringUUID,
|
||||
nullable=True,
|
||||
default=None,
|
||||
comment="Logical reference to upstream EE sys_users.id; mutually exclusive with started_by_account_id.",
|
||||
)
|
||||
started_at: Mapped[datetime | None] = mapped_column(
|
||||
sa.DateTime,
|
||||
|
||||
Generated
+7
-3
@@ -15220,6 +15220,8 @@ Set or reset the IM override for a contact. This endpoint is used to override th
|
||||
| default | Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
|
||||
### [POST] /workspaces/current/human-input/v2/im-sync-runs
|
||||
**Start or reuse a workspace run using the authenticated Account ID**
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
@@ -15231,7 +15233,7 @@ Set or reset the IM override for a contact. This endpoint is used to override th
|
||||
| default | Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/human-input/v2/im-sync-runs/latest
|
||||
Return the latest IM sync run summary. The UI uses finished_at as the explicit sync time; the response does not include started_by.
|
||||
Return the latest IM sync run summary. The UI uses finished_at as the explicit sync time; initiator_display_name is resolved from the initiating Account.
|
||||
|
||||
#### Responses
|
||||
|
||||
@@ -26777,6 +26779,7 @@ A trimmed version of `HumanInputContact` that only includes the fields needed fo
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| avatar_url | string | URL of the contact's avatar. | No |
|
||||
| created_at | integer | Timestamp when the contact was created. | Yes |
|
||||
| email | string | Primary contact email if one exists. | No |
|
||||
| id | string | Unique contact identifier. | Yes |
|
||||
| name | string | Display name shown in the contact directory. | Yes |
|
||||
|
||||
@@ -27139,8 +27142,8 @@ Stable reconciliation bucket for one synchronized directory entry.
|
||||
|
||||
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.
|
||||
The latest-only UI displays ``finished_at`` as the explicit sync time.
|
||||
The initiator display name is resolved from the initiating Dify Account.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
@@ -27149,6 +27152,7 @@ transport contract intentionally does not expose a ``started_by`` actor.
|
||||
| error_message | string | Terminal error message. Present only when the sync run status is `failed`. | No |
|
||||
| finished_at | integer | Unix timestamp in seconds when the sync run finished. This is the sync time displayed by the latest-only UI and is None while the run is unfinished. | No |
|
||||
| id | string | Unique sync run identifier. | Yes |
|
||||
| initiator_display_name | string | Current initiating Account name; empty for EE administrators or an unavailable Account. | Yes |
|
||||
| provider | [IMProvider](#improvider) | IM provider associated with the sync run. | Yes |
|
||||
| result_counts | [IMSyncRunResultCounts](#imsyncrunresultcounts) | Aggregate reconciliation counts for the current run snapshot. | Yes |
|
||||
| started_at | integer | Unix timestamp in seconds when the sync run started. | No |
|
||||
|
||||
@@ -122,6 +122,14 @@ class FormRepository(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
def get_submitted_forms(self, *, workflow_run_id: str) -> tuple[Form, ...]:
|
||||
"""Read accepted runtime submissions for one run in the bound tenant and app.
|
||||
|
||||
Exclude waiting, timed-out, expired, and delivery-test forms. Return
|
||||
detached snapshots without modifying forms or their submissions.
|
||||
"""
|
||||
...
|
||||
|
||||
def create_form(self, params: FormCreateParams) -> Form:
|
||||
"""Create an unsubmitted form with generated ID and timestamps.
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Explicit bidirectional mappings for IM domain values and ORM records."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import assert_never
|
||||
|
||||
from pydantic import NaiveDatetime
|
||||
|
||||
@@ -18,6 +19,12 @@ from core.human_input_v2.im_integration import (
|
||||
SyncResultFact,
|
||||
)
|
||||
from core.human_input_v2.im_integration.adapters.entities import ProviderUserId
|
||||
from core.human_input_v2.im_integration.sync_records import (
|
||||
AccountIMSyncInitiator,
|
||||
IMSyncInitiator,
|
||||
SysUserId,
|
||||
SysUserIMSyncInitiator,
|
||||
)
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
@@ -48,6 +55,19 @@ def _timestamp(value: datetime) -> NaiveDatetime:
|
||||
return ensure_naive_utc(value)
|
||||
|
||||
|
||||
def _sync_initiator_from_record(record: HumanInputIMSyncRun) -> IMSyncInitiator:
|
||||
"""Restore the identity guaranteed by the database's exactly-one constraint."""
|
||||
account_id = record.started_by_account_id
|
||||
sys_user_id = record.started_by_sys_user_id
|
||||
if account_id is not None:
|
||||
if sys_user_id is not None:
|
||||
raise AssertionError("IM sync run must contain exactly one initiating identity")
|
||||
return AccountIMSyncInitiator(AccountId(account_id))
|
||||
if sys_user_id is None:
|
||||
raise AssertionError("IM sync run must contain exactly one initiating identity")
|
||||
return SysUserIMSyncInitiator(SysUserId(sys_user_id))
|
||||
|
||||
|
||||
def sync_run_from_record(record: HumanInputIMSyncRun) -> IMSyncRun:
|
||||
"""Map one sync run record into its independent aggregate."""
|
||||
|
||||
@@ -61,9 +81,7 @@ def sync_run_from_record(record: HumanInputIMSyncRun) -> IMSyncRun:
|
||||
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_by=_sync_initiator_from_record(record),
|
||||
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,
|
||||
@@ -74,7 +92,17 @@ def sync_run_from_record(record: HumanInputIMSyncRun) -> IMSyncRun:
|
||||
|
||||
|
||||
def sync_run_to_record(run: IMSyncRun) -> HumanInputIMSyncRun:
|
||||
"""Map one sync aggregate into a detached record."""
|
||||
"""Map either initiating identity into exactly one column of a detached record."""
|
||||
|
||||
match run.started_by:
|
||||
case AccountIMSyncInitiator(account_id):
|
||||
started_by_account_id = str(account_id)
|
||||
started_by_sys_user_id = None
|
||||
case SysUserIMSyncInitiator(sys_user_id):
|
||||
started_by_account_id = None
|
||||
started_by_sys_user_id = str(sys_user_id)
|
||||
case unexpected:
|
||||
assert_never(unexpected)
|
||||
|
||||
record = HumanInputIMSyncRun(
|
||||
channel_id=run.channel_revision.channel_id,
|
||||
@@ -86,7 +114,8 @@ def sync_run_to_record(run: IMSyncRun) -> HumanInputIMSyncRun:
|
||||
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_by_account_id=started_by_account_id,
|
||||
started_by_sys_user_id=started_by_sys_user_id,
|
||||
started_at=run.started_at if run.started_at is not None else None,
|
||||
finished_at=run.finished_at if run.finished_at is not None else None,
|
||||
error_code=run.error_code,
|
||||
|
||||
@@ -19,7 +19,8 @@ from core.human_input_v2.im_integration import (
|
||||
SyncResultFact,
|
||||
SyncResultPage,
|
||||
)
|
||||
from core.human_input_v2.shared import AccountId, IMSyncResultId, IMSyncRunId
|
||||
from core.human_input_v2.im_integration.sync_records import IMSyncInitiator
|
||||
from core.human_input_v2.shared import IMSyncResultId, IMSyncRunId
|
||||
from libs.uuid_utils import uuidv7
|
||||
from models.human_input_v2 import HumanInputIMChannel, HumanInputIMSyncResult, HumanInputIMSyncRun
|
||||
from repositories.human_input_v2.im_binding_repository import IMBindingRepository
|
||||
@@ -42,9 +43,14 @@ class SQLAlchemyIMControlPlaneRepository:
|
||||
self,
|
||||
*,
|
||||
sync_run_id: IMSyncRunId,
|
||||
started_by_account_id: AccountId | None,
|
||||
started_by: IMSyncInitiator,
|
||||
now: NaiveDatetime,
|
||||
) -> ActiveRunDecision:
|
||||
"""Persist one initiating identity or return the unchanged active run.
|
||||
|
||||
The caller owns the transaction. The Channel lock serializes creation;
|
||||
a stale revision returns without creating a run.
|
||||
"""
|
||||
revision = IMChannelRevision(
|
||||
str(self._channel.id),
|
||||
self._channel.config_version,
|
||||
@@ -78,7 +84,7 @@ class SQLAlchemyIMControlPlaneRepository:
|
||||
sync_run_id=sync_run_id,
|
||||
channel_revision=revision,
|
||||
provider=self._channel.provider,
|
||||
started_by_account_id=started_by_account_id,
|
||||
started_by=started_by,
|
||||
now=now,
|
||||
)
|
||||
record = sync_run_to_record(run)
|
||||
|
||||
@@ -210,6 +210,18 @@ class SQLAlchemyFormRepository(FormRepository):
|
||||
)
|
||||
return tuple(_form_from_record(record) for record in records)
|
||||
|
||||
@override
|
||||
def get_submitted_forms(self, *, workflow_run_id: str) -> tuple[Form, ...]:
|
||||
"""Return only accepted runtime forms in the bound owner scope without writes."""
|
||||
records = self._session.scalars(
|
||||
self._query().where(
|
||||
HumanInputForm.workflow_run_id == workflow_run_id,
|
||||
HumanInputForm.form_kind == HumanInputFormKind.RUNTIME,
|
||||
HumanInputForm.status == HumanInputFormStatus.SUBMITTED,
|
||||
)
|
||||
)
|
||||
return tuple(_form_from_record(record) for record in records)
|
||||
|
||||
@override
|
||||
def create_form(self, params: FormCreateParams) -> Form:
|
||||
execution_ids = (params.workflow_run_id, params.node_execution_id)
|
||||
|
||||
@@ -13,6 +13,7 @@ from core.human_input_v2.im_integration.adapters.factory import build_im_provide
|
||||
from core.human_input_v2.shared import DeploymentScope, DirectoryScope, IMSyncRunId, TenantId, WorkspaceScope
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_key_provider import key_provider_manager
|
||||
from repositories.account.repository import SQLAlchemyAccountRepository
|
||||
from repositories.human_input_v2.contact import Contact, ContactQuery, ContactType
|
||||
from repositories.human_input_v2.im_channel_repository import IMChannel
|
||||
from repositories.human_input_v2.sqlalchemy_contact_repository import SQLAlchemyContactRepository
|
||||
@@ -103,7 +104,9 @@ def build_im_contact_sync_application(
|
||||
deployment_adapter_factory: IMChannelAdapterFactory | None = None,
|
||||
) -> IMContactSyncApplication:
|
||||
sessions = session_maker or sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
sync_service = IMSyncService(sessions, _resolve_channel, _dispatch)
|
||||
sync_service = IMSyncService(
|
||||
sessions, _resolve_channel, _dispatch, account_query=SQLAlchemyAccountRepository(sessions)
|
||||
)
|
||||
worker = IMContactSyncWorker(
|
||||
sync_service,
|
||||
_reconciliation_factory(
|
||||
@@ -137,7 +140,7 @@ def build_im_contact_sync_worker(
|
||||
|
||||
def build_im_sync_service(*, session_maker: sessionmaker[Session] | None = None) -> IMSyncService:
|
||||
sessions = session_maker or sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
return IMSyncService(sessions, _resolve_channel, _dispatch)
|
||||
return IMSyncService(sessions, _resolve_channel, _dispatch, account_query=SQLAlchemyAccountRepository(sessions))
|
||||
|
||||
|
||||
def _resolve_channel(session: Session, owner_scope: DirectoryScope) -> IMChannel | None:
|
||||
|
||||
@@ -39,6 +39,7 @@ from core.human_input_v2.im_integration import (
|
||||
)
|
||||
from core.human_input_v2.im_integration.adapters import IMProviderAdapter
|
||||
from core.human_input_v2.im_integration.adapters.entities import DirectoryEntry, DirectoryReadFailure, ProviderUserId
|
||||
from core.human_input_v2.im_integration.sync_records import AccountIMSyncInitiator
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
@@ -236,7 +237,7 @@ class IMChannelReconciliationService:
|
||||
# tenant only for Workspace Channels.
|
||||
if self._tenant_id is None or not any(entry.avatar is not None for entry in entries):
|
||||
return {}
|
||||
created_by = run.started_by_account_id
|
||||
created_by = run.started_by.account_id if isinstance(run.started_by, AccountIMSyncInitiator) else None
|
||||
if created_by is None:
|
||||
with self._session_factory() as session:
|
||||
owner_id = session.scalar(
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pydantic import NaiveDatetime
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
@@ -15,7 +16,8 @@ from core.human_input_v2.im_integration import (
|
||||
SynchronizedIMIdentityPage,
|
||||
SyncResultPage,
|
||||
)
|
||||
from core.human_input_v2.shared import AccountId, DirectoryScope, IMSyncRunId
|
||||
from core.human_input_v2.im_integration.sync_records import AccountIMSyncInitiator, IMSyncInitiator
|
||||
from core.human_input_v2.shared import DirectoryScope, IMSyncRunId
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.uuid_utils import uuidv7
|
||||
from models.human_input_v2 import HumanInputIMSyncRun
|
||||
@@ -23,6 +25,7 @@ from repositories.human_input_v2.im_channel_repository import IMChannel
|
||||
from repositories.human_input_v2.im_integration import SQLAlchemyIMControlPlaneRepository
|
||||
from repositories.human_input_v2.sqlalchemy_im_binding_repository import SQLAlchemyIMBindingRepository
|
||||
from repositories.human_input_v2.sqlalchemy_im_identity_repository import SQLAlchemyIMIdentityRepository
|
||||
from services.account_ports import AccountSnapshotQuery
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -47,6 +50,18 @@ type IMSyncRunDispatcher = Callable[[IMSyncRunId, DirectoryScope], None]
|
||||
type IMChannelResolver = Callable[[Session, DirectoryScope], IMChannel | None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMSyncRunSummary:
|
||||
"""A persisted run with its currently resolvable Dify Account name.
|
||||
|
||||
The name is empty for EE sys_users and deleted Accounts. CE/SaaS never
|
||||
resolves EE administrator identities.
|
||||
"""
|
||||
|
||||
run: IMSyncRun
|
||||
initiator_display_name: str
|
||||
|
||||
|
||||
class IMSyncService:
|
||||
"""Own Session boundaries around Channel-bound synchronization persistence."""
|
||||
|
||||
@@ -56,25 +71,34 @@ class IMSyncService:
|
||||
channel_resolver: IMChannelResolver,
|
||||
dispatcher: IMSyncRunDispatcher,
|
||||
*,
|
||||
account_query: AccountSnapshotQuery,
|
||||
clock: Callable[[], NaiveDatetime] = naive_utc_now,
|
||||
run_id_factory: Callable[[], IMSyncRunId] = lambda: IMSyncRunId(str(uuidv7())),
|
||||
) -> None:
|
||||
self._session_factory = session_factory
|
||||
self._channel_resolver = channel_resolver
|
||||
self._dispatcher = dispatcher
|
||||
self._account_query = account_query
|
||||
self._clock = clock
|
||||
self._run_id_factory = run_id_factory
|
||||
|
||||
def create_or_get_active_run(
|
||||
self,
|
||||
owner_scope: DirectoryScope,
|
||||
started_by_account_id: AccountId | None,
|
||||
) -> IMSyncRun:
|
||||
started_by: IMSyncInitiator,
|
||||
) -> IMSyncRunSummary:
|
||||
"""Create or reuse a run from one authenticated initiating identity.
|
||||
|
||||
CE/SaaS supplies an Account ID; EE supplies a sys_users.id. Reusing an
|
||||
active run preserves its original initiator. Commit precedes dispatch;
|
||||
dispatch failure leaves the queued run available for retry. The returned
|
||||
display name is resolved from the Account after persistence, never stored.
|
||||
"""
|
||||
with self._session_factory() as session, session.begin():
|
||||
channel = self._require_channel(session, owner_scope)
|
||||
decision = SQLAlchemyIMControlPlaneRepository(session, channel).create_or_get_active_run(
|
||||
sync_run_id=self._run_id_factory(),
|
||||
started_by_account_id=started_by_account_id,
|
||||
started_by=started_by,
|
||||
now=self._clock(),
|
||||
)
|
||||
if decision.kind is ActiveRunDecisionKind.STALE_REVISION:
|
||||
@@ -89,9 +113,14 @@ class IMSyncService:
|
||||
raise IMSyncDispatchUnavailableError(
|
||||
"IM synchronization dispatch is temporarily unavailable"
|
||||
) from error
|
||||
return decision.run
|
||||
return self._summarize_run(decision.run)
|
||||
|
||||
def get_latest_run(self, owner_scope: DirectoryScope) -> IMSyncRun:
|
||||
def get_latest_run(self, owner_scope: DirectoryScope) -> IMSyncRunSummary:
|
||||
"""Read the current Channel's latest run and resolve its Account name."""
|
||||
return self._summarize_run(self._load_latest_run(owner_scope))
|
||||
|
||||
def _load_latest_run(self, owner_scope: DirectoryScope) -> IMSyncRun:
|
||||
"""Load the current Channel's latest run or raise the existing not-found error."""
|
||||
with self._session_factory() as session:
|
||||
channel = self._require_channel(session, owner_scope)
|
||||
run = SQLAlchemyIMControlPlaneRepository(session, channel).load_latest_sync_run()
|
||||
@@ -107,7 +136,7 @@ class IMSyncService:
|
||||
page: int,
|
||||
limit: int,
|
||||
) -> SyncResultPage:
|
||||
latest_run = self.get_latest_run(owner_scope)
|
||||
latest_run = self._load_latest_run(owner_scope)
|
||||
with self._session_factory() as session:
|
||||
channel = self._require_channel(session, owner_scope)
|
||||
return SQLAlchemyIMControlPlaneRepository(session, channel).page_sync_results(
|
||||
@@ -152,6 +181,13 @@ class IMSyncService:
|
||||
raise IMChannelNotConfiguredError("Owner has no IM Channel")
|
||||
return channel
|
||||
|
||||
def _summarize_run(self, run: IMSyncRun) -> IMSyncRunSummary:
|
||||
"""Resolve only Account initiators; EE names stay empty."""
|
||||
if not isinstance(run.started_by, AccountIMSyncInitiator):
|
||||
return IMSyncRunSummary(run, "")
|
||||
account = self._account_query.get(str(run.started_by.account_id))
|
||||
return IMSyncRunSummary(run, account.name if account is not None else "")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"IMChannelNotConfiguredError",
|
||||
|
||||
@@ -11,14 +11,16 @@ from pydantic import JsonValue, TypeAdapter
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.app.file_access import DatabaseFileAccessController
|
||||
from core.app.file_access.scope import get_current_file_access_scope, grant_upload_file_access
|
||||
from core.human_input_v2.entities import IMProvider
|
||||
from core.human_input_v2.resolved_form import FileInput, FileListInput, MarkdownFragment, ParagraphInput, SelectInput
|
||||
from core.human_input_v2.shared.values import AccountId, TenantId, WorkspaceScope
|
||||
from core.workflow.file_reference import resolve_file_record_id
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormStatus
|
||||
from factories.file_factory import build_from_mapping
|
||||
from factories.human_input_v2_form_presentation import refresh_form_file_urls
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from graphon.file import FileUploadConfig
|
||||
from graphon.file import FileTransferMethod, FileUploadConfig
|
||||
from graphon.variables.factory import build_segment
|
||||
from graphon.variables.segments import ArrayFileSegment, FileSegment, Segment, StringSegment
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
@@ -144,6 +146,41 @@ class HumanInputSubmissionService:
|
||||
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
def grant_submitted_file_access(self, *, app_id: str, workflow_run_id: str) -> None:
|
||||
"""Restore accepted HITL file grants inside the initiating EndUser's scope.
|
||||
|
||||
The caller supplies its authorized app and run before worker contexts
|
||||
are captured. Grant only persisted submissions in the current tenant,
|
||||
including earlier HITL steps across repeated pauses. Keep persistent
|
||||
file ownership and all unrelated grants unchanged. Account and absent
|
||||
scopes require no grants and perform no reads; read failures propagate
|
||||
without changing the scope.
|
||||
"""
|
||||
scope = get_current_file_access_scope()
|
||||
if scope is None or not scope.requires_user_ownership:
|
||||
return
|
||||
|
||||
with self._session_factory() as session:
|
||||
forms = SQLAlchemyFormRepository(session, TenantId(scope.tenant_id), app_id).get_submitted_forms(
|
||||
workflow_run_id=workflow_run_id
|
||||
)
|
||||
file_ids: set[str] = set()
|
||||
for form in forms:
|
||||
assert form.submission is not None, "Submitted form must contain its accepted submission"
|
||||
for segment in form.submission.normalized_submission_data.values():
|
||||
if isinstance(segment, FileSegment):
|
||||
files = [segment.value]
|
||||
elif isinstance(segment, ArrayFileSegment):
|
||||
files = segment.value
|
||||
else:
|
||||
files = []
|
||||
for file in files:
|
||||
if file.transfer_method in {FileTransferMethod.LOCAL_FILE, FileTransferMethod.REMOTE_URL}:
|
||||
file_id = resolve_file_record_id(file.reference)
|
||||
if file_id is not None:
|
||||
file_ids.add(file_id)
|
||||
grant_upload_file_access(file_ids)
|
||||
|
||||
def get_service_api_form(
|
||||
self, *, actor: ServiceAPIFormActor, form_token: str, ip_address: str | None = None
|
||||
) -> Form:
|
||||
|
||||
@@ -40,6 +40,7 @@ from models.workflow import Workflow, WorkflowNodeExecutionTriggeredFrom, Workfl
|
||||
from repositories.factory import DifyAPIRepositoryFactory
|
||||
from repositories.sqlalchemy_api_workflow_run_repository import WorkflowRunNotPausedError
|
||||
from services.app_task_service import AppTaskService
|
||||
from services.human_input_v2.submission_service import HumanInputSubmissionService
|
||||
from services.workflow_execution_identity import build_workflow_execution_identity
|
||||
from services.workflow_run_agg import WorkflowRunAgg
|
||||
|
||||
@@ -715,6 +716,11 @@ def _resume_advanced_chat(
|
||||
graph_runtime_state=graph_runtime_state,
|
||||
pause_state_config=pause_state_config,
|
||||
response_stream_filter=response_stream_filter,
|
||||
restore_file_access=partial(
|
||||
HumanInputSubmissionService(session_factory=session_factory).grant_submitted_file_access,
|
||||
app_id=app_model.id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
),
|
||||
session=session,
|
||||
)
|
||||
except Exception:
|
||||
@@ -782,6 +788,11 @@ def _resume_workflow(
|
||||
workflow_node_execution_repository=workflow_node_execution_repository,
|
||||
pause_state_config=pause_state_config,
|
||||
response_stream_filter=response_stream_filter,
|
||||
restore_file_access=partial(
|
||||
HumanInputSubmissionService(session_factory=session_factory).grant_submitted_file_access,
|
||||
app_id=app_model.id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to resume workflow execution for workflow run %s", workflow_run_id)
|
||||
|
||||
@@ -7,6 +7,7 @@ with appropriate retry policies and error handling.
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
@@ -30,6 +31,7 @@ from models.workflow import Workflow, WorkflowNodeExecutionTriggeredFrom, Workfl
|
||||
from repositories.factory import DifyAPIRepositoryFactory
|
||||
from services.app_task_service import AppTaskService
|
||||
from services.errors.app import WorkflowNotFoundError
|
||||
from services.human_input_v2.submission_service import HumanInputSubmissionService
|
||||
from services.trigger.execution_layer import TriggerPostLayer
|
||||
from services.workflow.entities import (
|
||||
WorkflowResumeTaskData,
|
||||
@@ -184,6 +186,11 @@ def resume_workflow_execution(task_data_dict: dict[str, Any]) -> None:
|
||||
graph_engine_layers=graph_engine_layers,
|
||||
pause_state_config=pause_config,
|
||||
response_stream_filter=response_stream_filter,
|
||||
restore_file_access=partial(
|
||||
HumanInputSubmissionService(session_factory=session_factory).grant_submitted_file_access,
|
||||
app_id=app_model.id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
),
|
||||
)
|
||||
workflow_run_repo.delete_workflow_pause(pause_entity)
|
||||
|
||||
|
||||
+2
-1
@@ -23,6 +23,7 @@ from core.human_input_v2.im_integration import (
|
||||
SyncIdentitySnapshot,
|
||||
SyncResultFact,
|
||||
)
|
||||
from core.human_input_v2.im_integration.sync_records import AccountIMSyncInitiator
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
@@ -275,7 +276,7 @@ def test_latest_result_http_projection_reads_every_persisted_bucket(
|
||||
sync_run_id=IMSyncRunId("00000000-0000-0000-0000-000000000501"),
|
||||
channel_revision=IMChannelRevision(str(channel.id), channel.config_version),
|
||||
provider=channel.provider,
|
||||
started_by_account_id=AccountId(account.id),
|
||||
started_by=AccountIMSyncInitiator(AccountId(account.id)),
|
||||
now=_NOW,
|
||||
),
|
||||
status=IMSyncRunStatus.SUCCEEDED,
|
||||
|
||||
+2
-1
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.human_input_v2.entities import IMProvider
|
||||
from core.human_input_v2.im_integration import ActiveRunDecisionKind
|
||||
from core.human_input_v2.im_integration.sync_records import SysUserId, SysUserIMSyncInitiator
|
||||
from core.human_input_v2.shared import ContactId, IMIdentityId, IMSyncRunId
|
||||
from extensions.ext_database import db
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
@@ -99,7 +100,7 @@ def test_concurrent_sync_triggers_create_at_most_one_active_run(flask_req_ctx: o
|
||||
with session_maker.begin() as session:
|
||||
decision = SQLAlchemyIMControlPlaneRepository(session, channel).create_or_get_active_run(
|
||||
sync_run_id=IMSyncRunId(str(uuidv7())),
|
||||
started_by_account_id=None,
|
||||
started_by=SysUserIMSyncInitiator(SysUserId("00000000-0000-0000-0000-000000000999")),
|
||||
now=naive_utc_now(),
|
||||
)
|
||||
return decision.kind
|
||||
|
||||
@@ -30,6 +30,7 @@ from core.human_input_v2.im_integration.adapters import (
|
||||
IMWebhookHandler,
|
||||
ProviderUserId,
|
||||
)
|
||||
from core.human_input_v2.im_integration.sync_records import AccountIMSyncInitiator, SysUserId, SysUserIMSyncInitiator
|
||||
from core.human_input_v2.shared import AccountId, ContactId, DirectoryScope, IMSyncRunId, TenantId, WorkspaceScope
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.human_input_v2 import (
|
||||
@@ -41,6 +42,7 @@ from models.human_input_v2 import (
|
||||
HumanInputIMSyncResult,
|
||||
HumanInputIMSyncRun,
|
||||
)
|
||||
from repositories.account.repository import SQLAlchemyAccountRepository
|
||||
from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials
|
||||
from repositories.human_input_v2.contact import Contact
|
||||
from repositories.human_input_v2.im_channel_repository import IMChannel, IMChannelId, IMChannelStatus, WebhookId
|
||||
@@ -151,7 +153,12 @@ class _SyncContext:
|
||||
return WorkspaceIMChannelReader(session, self.scope.id).get()
|
||||
|
||||
def service(self) -> IMSyncService:
|
||||
return IMSyncService(self.sessions, self.resolve_channel, lambda _run, _scope: None)
|
||||
return IMSyncService(
|
||||
self.sessions,
|
||||
self.resolve_channel,
|
||||
lambda _run, _scope: None,
|
||||
account_query=SQLAlchemyAccountRepository(self.sessions),
|
||||
)
|
||||
|
||||
def worker(self, directory: _Directory | None = None) -> IMContactSyncWorker:
|
||||
def reconciliation(scope: DirectoryScope) -> IMChannelReconciliationService:
|
||||
@@ -201,7 +208,7 @@ def sync_context(sync_engine: Engine) -> _SyncContext:
|
||||
def test_revision_rotation_retires_old_run_and_new_worker_succeeds(
|
||||
sync_context: _SyncContext, status: IMSyncRunStatus, recovery: str
|
||||
) -> None:
|
||||
old = sync_context.service().create_or_get_active_run(sync_context.scope, None)
|
||||
old = sync_context.service().create_or_get_active_run(sync_context.scope, AccountIMSyncInitiator(_ACCOUNT_ID)).run
|
||||
with sync_context.sessions.begin() as session:
|
||||
session.get_one(HumanInputIMSyncRun, str(old.id)).status = status
|
||||
sync_context.rotate()
|
||||
@@ -209,7 +216,9 @@ def test_revision_rotation_retires_old_run_and_new_worker_succeeds(
|
||||
terminal = sync_context.worker().execute(old.id, sync_context.scope)
|
||||
assert terminal.status is IMSyncRunStatus.FAILED
|
||||
assert terminal.error_code == "stale_revision"
|
||||
current = sync_context.service().create_or_get_active_run(sync_context.scope, None)
|
||||
current = (
|
||||
sync_context.service().create_or_get_active_run(sync_context.scope, AccountIMSyncInitiator(_ACCOUNT_ID)).run
|
||||
)
|
||||
assert current.id != old.id
|
||||
assert current.channel_revision.config_version == 2
|
||||
assert current.status is IMSyncRunStatus.QUEUED
|
||||
@@ -233,14 +242,18 @@ def test_revision_rotation_retires_old_run_and_new_worker_succeeds(
|
||||
|
||||
|
||||
def test_concurrent_old_workers_and_triggers_leave_one_new_active_run(sync_context: _SyncContext) -> None:
|
||||
old = sync_context.service().create_or_get_active_run(sync_context.scope, None)
|
||||
old = sync_context.service().create_or_get_active_run(sync_context.scope, AccountIMSyncInitiator(_ACCOUNT_ID)).run
|
||||
sync_context.rotate()
|
||||
barrier = Barrier(4)
|
||||
|
||||
def execute(trigger: bool) -> IMSyncRun:
|
||||
barrier.wait(timeout=10)
|
||||
if trigger:
|
||||
return sync_context.service().create_or_get_active_run(sync_context.scope, None)
|
||||
return (
|
||||
sync_context.service()
|
||||
.create_or_get_active_run(sync_context.scope, AccountIMSyncInitiator(_ACCOUNT_ID))
|
||||
.run
|
||||
)
|
||||
return sync_context.worker().execute(old.id, sync_context.scope)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||
@@ -267,14 +280,18 @@ def test_concurrent_old_workers_and_triggers_leave_one_new_active_run(sync_conte
|
||||
|
||||
|
||||
def test_late_old_apply_cannot_overwrite_new_revision_state(sync_context: _SyncContext) -> None:
|
||||
old = sync_context.service().create_or_get_active_run(sync_context.scope, None)
|
||||
old = sync_context.service().create_or_get_active_run(sync_context.scope, AccountIMSyncInitiator(_ACCOUNT_ID)).run
|
||||
entered, resume = Event(), Event()
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
pending = executor.submit(sync_context.worker(_Directory(entered, resume)).execute, old.id, sync_context.scope)
|
||||
try:
|
||||
assert entered.wait(timeout=10)
|
||||
sync_context.rotate()
|
||||
current = sync_context.service().create_or_get_active_run(sync_context.scope, None)
|
||||
current = (
|
||||
sync_context.service()
|
||||
.create_or_get_active_run(sync_context.scope, AccountIMSyncInitiator(_ACCOUNT_ID))
|
||||
.run
|
||||
)
|
||||
assert sync_context.worker().execute(current.id, sync_context.scope).status is IMSyncRunStatus.SUCCEEDED
|
||||
finally:
|
||||
resume.set()
|
||||
@@ -296,7 +313,7 @@ def test_late_old_apply_cannot_overwrite_new_revision_state(sync_context: _SyncC
|
||||
|
||||
|
||||
def test_stale_completion_is_scoped_current_and_caller_rollback_owned(sync_context: _SyncContext) -> None:
|
||||
old = sync_context.service().create_or_get_active_run(sync_context.scope, None)
|
||||
old = sync_context.service().create_or_get_active_run(sync_context.scope, AccountIMSyncInitiator(_ACCOUNT_ID)).run
|
||||
current = sync_context.rotate()
|
||||
with sync_context.sessions() as session:
|
||||
assert (
|
||||
@@ -307,7 +324,9 @@ def test_stale_completion_is_scoped_current_and_caller_rollback_owned(sync_conte
|
||||
)
|
||||
with sync_context.sessions() as session:
|
||||
replacement = SQLAlchemyIMControlPlaneRepository(session, current).create_or_get_active_run(
|
||||
sync_run_id=IMSyncRunId(str(uuid4())), started_by_account_id=None, now=naive_utc_now()
|
||||
sync_run_id=IMSyncRunId(str(uuid4())),
|
||||
started_by=SysUserIMSyncInitiator(SysUserId("00000000-0000-0000-0000-000000000999")),
|
||||
now=naive_utc_now(),
|
||||
)
|
||||
assert replacement.run is not None
|
||||
assert replacement.run.id != old.id
|
||||
|
||||
@@ -39,6 +39,7 @@ def test_sync_run_exposes_captured_revision() -> None:
|
||||
"provider": "feishu",
|
||||
"channel_id": "channel-id",
|
||||
"channel_config_version": 4,
|
||||
"initiator_display_name": "Sync Admin",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ from dataclasses import replace
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
from inspect import unwrap
|
||||
from typing import Never, override
|
||||
from typing import override
|
||||
from unittest.mock import create_autospec
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
@@ -50,10 +51,12 @@ from core.human_input_v2.im_integration import (
|
||||
SyncResultFact,
|
||||
SyncResultPage,
|
||||
)
|
||||
from core.human_input_v2.im_integration.sync_records import AccountIMSyncInitiator, IMSyncInitiator
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
DeploymentScope,
|
||||
DirectoryScope,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IMSyncResultId,
|
||||
@@ -77,6 +80,7 @@ from services.human_input_v2.im_contact_sync.service import (
|
||||
IMSyncDispatchUnavailableError,
|
||||
IMSyncRevisionChangedError,
|
||||
IMSyncRunNotFoundError,
|
||||
IMSyncRunSummary,
|
||||
)
|
||||
from tests.helpers.unit.model_factories import make_account
|
||||
|
||||
@@ -87,7 +91,7 @@ _RUN = IMSyncRun.create(
|
||||
sync_run_id=IMSyncRunId("run-1"),
|
||||
channel_revision=IMChannelRevision("channel-1", 3),
|
||||
provider=IMProvider.FEISHU,
|
||||
started_by_account_id=AccountId("account-1"),
|
||||
started_by=AccountIMSyncInitiator(AccountId("account-1")),
|
||||
now=_NOW,
|
||||
)
|
||||
_RESULT = SyncResultFact(
|
||||
@@ -117,39 +121,39 @@ _RESULT = SyncResultFact(
|
||||
class _SyncService:
|
||||
def create_or_get_active_run(
|
||||
self,
|
||||
organization_scope: WorkspaceScope,
|
||||
started_by_account_id: AccountId,
|
||||
) -> IMSyncRun:
|
||||
assert organization_scope == WorkspaceScope(id=TenantId("workspace-1"))
|
||||
assert started_by_account_id == AccountId("account-1")
|
||||
return _RUN
|
||||
owner_scope: DirectoryScope,
|
||||
started_by: IMSyncInitiator,
|
||||
) -> IMSyncRunSummary:
|
||||
assert owner_scope == WorkspaceScope(id=TenantId("workspace-1"))
|
||||
assert started_by == AccountIMSyncInitiator(AccountId("account-1"))
|
||||
return IMSyncRunSummary(_RUN, "Editor")
|
||||
|
||||
def get_latest_run(self, organization_scope: WorkspaceScope) -> IMSyncRun:
|
||||
assert organization_scope == WorkspaceScope(id=TenantId("workspace-1"))
|
||||
return _RUN
|
||||
def get_latest_run(self, owner_scope: DirectoryScope) -> IMSyncRunSummary:
|
||||
assert owner_scope == WorkspaceScope(id=TenantId("workspace-1"))
|
||||
return IMSyncRunSummary(_RUN, "Editor")
|
||||
|
||||
def list_latest_results(
|
||||
self,
|
||||
organization_scope: WorkspaceScope,
|
||||
owner_scope: DirectoryScope,
|
||||
result_type: IMSyncResultType,
|
||||
*,
|
||||
page: int,
|
||||
limit: int,
|
||||
) -> SyncResultPage:
|
||||
assert organization_scope == WorkspaceScope(id=TenantId("workspace-1"))
|
||||
assert owner_scope == WorkspaceScope(id=TenantId("workspace-1"))
|
||||
assert result_type is IMSyncResultType.NOT_MATCHED
|
||||
assert (page, limit) == (1, 20)
|
||||
return SyncResultPage((_RESULT,), page=page, limit=limit, total=1)
|
||||
|
||||
def search_identities(
|
||||
self,
|
||||
organization_scope: WorkspaceScope,
|
||||
owner_scope: DirectoryScope,
|
||||
*,
|
||||
keyword: str | None,
|
||||
page: int,
|
||||
limit: int,
|
||||
) -> SynchronizedIMIdentityPage:
|
||||
assert organization_scope == WorkspaceScope(id=TenantId("workspace-1"))
|
||||
assert owner_scope == WorkspaceScope(id=TenantId("workspace-1"))
|
||||
assert (keyword, page, limit) == ("provider-user", 1, 20)
|
||||
return SynchronizedIMIdentityPage(
|
||||
(
|
||||
@@ -188,26 +192,42 @@ def _contact_view(scope: IMBindingScope) -> ContactIMBindingView:
|
||||
|
||||
|
||||
class _BindingService:
|
||||
def set_organization_binding(self, **kwargs: object) -> ContactIMBindingView:
|
||||
assert kwargs == {
|
||||
"organization_scope": WorkspaceScope(id=TenantId("workspace-1")),
|
||||
"tenant_id": TenantId("workspace-1"),
|
||||
"contact_id": ContactId("00000000-0000-0000-0000-000000000001"),
|
||||
"identity_id": IMIdentityId("identity-1"),
|
||||
"bound_by_account_id": AccountId("account-1"),
|
||||
}
|
||||
def set_organization_binding(
|
||||
self,
|
||||
*,
|
||||
organization_scope: DirectoryScope,
|
||||
tenant_id: TenantId,
|
||||
contact_id: ContactId,
|
||||
identity_id: IMIdentityId,
|
||||
bound_by_account_id: AccountId | None,
|
||||
) -> ContactIMBindingView:
|
||||
assert organization_scope == WorkspaceScope(id=TenantId("workspace-1"))
|
||||
assert tenant_id == TenantId("workspace-1")
|
||||
assert contact_id == ContactId("00000000-0000-0000-0000-000000000001")
|
||||
assert identity_id == IMIdentityId("identity-1")
|
||||
assert bound_by_account_id == AccountId("account-1")
|
||||
return _contact_view(IMBindingScope.ORGANIZATION)
|
||||
|
||||
def delete_organization_binding(self, **kwargs: object) -> None:
|
||||
assert kwargs == {
|
||||
"organization_scope": WorkspaceScope(id=TenantId("workspace-1")),
|
||||
"contact_id": ContactId("00000000-0000-0000-0000-000000000001"),
|
||||
}
|
||||
def delete_organization_binding(self, *, organization_scope: DirectoryScope, contact_id: ContactId) -> None:
|
||||
assert organization_scope == WorkspaceScope(id=TenantId("workspace-1"))
|
||||
assert contact_id == ContactId("00000000-0000-0000-0000-000000000001")
|
||||
|
||||
def set_workspace_override(self, **_kwargs: object) -> ContactIMBindingView:
|
||||
def set_workspace_override(
|
||||
self,
|
||||
*,
|
||||
organization_scope: DirectoryScope,
|
||||
tenant_id: TenantId,
|
||||
contact_id: ContactId,
|
||||
identity_id: IMIdentityId,
|
||||
bound_by_account_id: AccountId | None,
|
||||
) -> ContactIMBindingView:
|
||||
del organization_scope, tenant_id, contact_id, identity_id, bound_by_account_id
|
||||
return _contact_view(IMBindingScope.WORKSPACE)
|
||||
|
||||
def reset_workspace_override(self, **_kwargs: object) -> ContactIMBindingView:
|
||||
def reset_workspace_override(
|
||||
self, *, organization_scope: DirectoryScope, tenant_id: TenantId, contact_id: ContactId
|
||||
) -> ContactIMBindingView:
|
||||
del organization_scope, tenant_id, contact_id
|
||||
return _contact_view(IMBindingScope.ORGANIZATION)
|
||||
|
||||
|
||||
@@ -265,6 +285,7 @@ def test_sync_command_and_queries_map_transport_neutral_results(
|
||||
"provider": "feishu",
|
||||
"channel_id": "channel-1",
|
||||
"channel_config_version": 3,
|
||||
"initiator_display_name": "Editor",
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -310,11 +331,11 @@ def test_sync_dispatch_unavailable_has_stable_retryable_http_mapping(
|
||||
) -> None:
|
||||
error = IMSyncDispatchUnavailableError("IM synchronization dispatch is temporarily unavailable")
|
||||
|
||||
class SyncService:
|
||||
def create_or_get_active_run(self, _scope: object, _account_id: object) -> Never:
|
||||
raise error
|
||||
|
||||
monkeypatch.setattr(application.sync_service, "create_or_get_active_run", SyncService().create_or_get_active_run)
|
||||
monkeypatch.setattr(
|
||||
application.sync_service,
|
||||
"create_or_get_active_run",
|
||||
create_autospec(application.sync_service.create_or_get_active_run, side_effect=error),
|
||||
)
|
||||
|
||||
with app.test_request_context(method="POST"):
|
||||
response, status = unwrap(WorkspaceIMSyncRunsApi.post)(
|
||||
@@ -346,22 +367,21 @@ def test_write_unavailable_has_stable_retryable_http_mapping(
|
||||
)
|
||||
error = IMWriteUnavailableError("IM write is temporarily unavailable")
|
||||
|
||||
class SyncService:
|
||||
def create_or_get_active_run(self, _scope: object, _account_id: object) -> Never:
|
||||
raise error
|
||||
|
||||
class BindingService:
|
||||
def set_organization_binding(self, **_kwargs: object) -> Never:
|
||||
raise error
|
||||
|
||||
def set_workspace_override(self, **_kwargs: object) -> Never:
|
||||
raise error
|
||||
|
||||
monkeypatch.setattr(application.sync_service, "create_or_get_active_run", SyncService().create_or_get_active_run)
|
||||
monkeypatch.setattr(
|
||||
application.binding_service, "set_organization_binding", BindingService().set_organization_binding
|
||||
application.sync_service,
|
||||
"create_or_get_active_run",
|
||||
create_autospec(application.sync_service.create_or_get_active_run, side_effect=error),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
application.binding_service,
|
||||
"set_organization_binding",
|
||||
create_autospec(application.binding_service.set_organization_binding, side_effect=error),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
application.binding_service,
|
||||
"set_workspace_override",
|
||||
create_autospec(application.binding_service.set_workspace_override, side_effect=error),
|
||||
)
|
||||
monkeypatch.setattr(application.binding_service, "set_workspace_override", BindingService().set_workspace_override)
|
||||
account = make_account(account_id="account-1", name="Editor", email="editor@example.com")
|
||||
contact_id = "00000000-0000-0000-0000-000000000001"
|
||||
if command == "sync_create":
|
||||
@@ -399,7 +419,7 @@ def test_write_unavailable_has_stable_retryable_http_mapping(
|
||||
contact_snapshot=SyncContactSnapshot(
|
||||
ContactId("contact-1"),
|
||||
"Reviewer",
|
||||
None,
|
||||
"snapshot@example.com",
|
||||
None,
|
||||
_CONTACT_CREATED_AT,
|
||||
),
|
||||
@@ -410,7 +430,7 @@ def test_write_unavailable_has_stable_retryable_http_mapping(
|
||||
contact_id=ContactId("contact-1"),
|
||||
binding_id=IMBindingId("binding-1"),
|
||||
removal_reason=IMSyncRemovalReason.NOT_PRESENT_IN_DIRECTORY,
|
||||
contact_snapshot=SyncContactSnapshot(ContactId("contact-1"), "Reviewer", None, None),
|
||||
contact_snapshot=SyncContactSnapshot(ContactId("contact-1"), "Reviewer", "snapshot@example.com", None),
|
||||
identity_snapshot=SyncIdentitySnapshot(
|
||||
IMIdentityId("identity-1"),
|
||||
IMProvider.FEISHU,
|
||||
@@ -435,10 +455,11 @@ def test_latest_results_map_every_discriminated_bucket(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sync_result: SyncResultFact,
|
||||
) -> None:
|
||||
class SyncService:
|
||||
class SyncService(_SyncService):
|
||||
@override
|
||||
def list_latest_results(
|
||||
self,
|
||||
_scope: object,
|
||||
owner_scope: DirectoryScope,
|
||||
result_type: IMSyncResultType,
|
||||
*,
|
||||
page: int,
|
||||
@@ -461,6 +482,8 @@ def test_latest_results_map_every_discriminated_bucket(
|
||||
assert result_payload["type"] == sync_result.result_type.value
|
||||
if sync_result.result_type in (IMSyncResultType.ADDED, IMSyncResultType.SKIPPED, IMSyncResultType.REMOVED):
|
||||
assert "type" not in result_payload["contact"]
|
||||
assert sync_result.contact_snapshot is not None
|
||||
assert result_payload["contact"]["email"] == sync_result.contact_snapshot.email
|
||||
if sync_result.result_type is IMSyncResultType.ADDED:
|
||||
assert result_payload["contact"]["created_at"] == 1752127200
|
||||
if sync_result.result_type is IMSyncResultType.REMOVED:
|
||||
@@ -569,25 +592,50 @@ def test_enterprise_override_and_identity_search_use_the_deployment_directory(
|
||||
config_overrides: Callable[..., None],
|
||||
) -> None:
|
||||
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE)
|
||||
scopes = []
|
||||
scopes: list[DirectoryScope] = []
|
||||
|
||||
class BindingService(_BindingService):
|
||||
@override
|
||||
def set_workspace_override(self, **kwargs: object) -> ContactIMBindingView:
|
||||
scopes.append(kwargs["organization_scope"])
|
||||
return super().set_workspace_override(**kwargs)
|
||||
def set_workspace_override(
|
||||
self,
|
||||
*,
|
||||
organization_scope: DirectoryScope,
|
||||
tenant_id: TenantId,
|
||||
contact_id: ContactId,
|
||||
identity_id: IMIdentityId,
|
||||
bound_by_account_id: AccountId | None,
|
||||
) -> ContactIMBindingView:
|
||||
scopes.append(organization_scope)
|
||||
return super().set_workspace_override(
|
||||
organization_scope=organization_scope,
|
||||
tenant_id=tenant_id,
|
||||
contact_id=contact_id,
|
||||
identity_id=identity_id,
|
||||
bound_by_account_id=bound_by_account_id,
|
||||
)
|
||||
|
||||
@override
|
||||
def reset_workspace_override(self, **kwargs: object) -> ContactIMBindingView:
|
||||
scopes.append(kwargs["organization_scope"])
|
||||
return super().reset_workspace_override(**kwargs)
|
||||
def reset_workspace_override(
|
||||
self, *, organization_scope: DirectoryScope, tenant_id: TenantId, contact_id: ContactId
|
||||
) -> ContactIMBindingView:
|
||||
scopes.append(organization_scope)
|
||||
return super().reset_workspace_override(
|
||||
organization_scope=organization_scope,
|
||||
tenant_id=tenant_id,
|
||||
contact_id=contact_id,
|
||||
)
|
||||
|
||||
class SyncService(_SyncService):
|
||||
@override
|
||||
def search_identities(
|
||||
self, organization_scope: WorkspaceScope, **_kwargs: object
|
||||
self,
|
||||
owner_scope: DirectoryScope,
|
||||
*,
|
||||
keyword: str | None,
|
||||
page: int,
|
||||
limit: int,
|
||||
) -> SynchronizedIMIdentityPage:
|
||||
scopes.append(organization_scope)
|
||||
scopes.append(owner_scope)
|
||||
return SynchronizedIMIdentityPage((), page=1, limit=20, total=0)
|
||||
|
||||
monkeypatch.setattr(application.binding_service, "set_workspace_override", BindingService().set_workspace_override)
|
||||
@@ -638,13 +686,10 @@ def test_expected_application_errors_have_stable_http_mapping(
|
||||
expected_code: str,
|
||||
) -> None:
|
||||
if isinstance(error, IMBindingCommandError):
|
||||
|
||||
class BindingService:
|
||||
def set_organization_binding(self, **_kwargs: object) -> Never:
|
||||
raise error
|
||||
|
||||
monkeypatch.setattr(
|
||||
application.binding_service, "set_organization_binding", BindingService().set_organization_binding
|
||||
application.binding_service,
|
||||
"set_organization_binding",
|
||||
create_autospec(application.binding_service.set_organization_binding, side_effect=error),
|
||||
)
|
||||
handler = unwrap(WorkspaceContactIMBindingsApi.put)
|
||||
request_context = app.test_request_context(method="PUT", json={"identity_id": "identity-1"})
|
||||
@@ -655,13 +700,10 @@ def test_expected_application_errors_have_stable_http_mapping(
|
||||
"00000000-0000-0000-0000-000000000001",
|
||||
)
|
||||
elif isinstance(error, IMSyncRevisionChangedError):
|
||||
|
||||
class SyncService:
|
||||
def create_or_get_active_run(self, _scope: object, _account_id: object) -> Never:
|
||||
raise error
|
||||
|
||||
monkeypatch.setattr(
|
||||
application.sync_service, "create_or_get_active_run", SyncService().create_or_get_active_run
|
||||
application.sync_service,
|
||||
"create_or_get_active_run",
|
||||
create_autospec(application.sync_service.create_or_get_active_run, side_effect=error),
|
||||
)
|
||||
handler = unwrap(WorkspaceIMSyncRunsApi.post)
|
||||
request_context = app.test_request_context(method="POST")
|
||||
@@ -671,12 +713,11 @@ def test_expected_application_errors_have_stable_http_mapping(
|
||||
make_account(account_id="account-1", name="Editor", email="editor@example.com"),
|
||||
)
|
||||
else:
|
||||
|
||||
class SyncService:
|
||||
def get_latest_run(self, _scope: object) -> Never:
|
||||
raise error
|
||||
|
||||
monkeypatch.setattr(application.sync_service, "get_latest_run", SyncService().get_latest_run)
|
||||
monkeypatch.setattr(
|
||||
application.sync_service,
|
||||
"get_latest_run",
|
||||
create_autospec(application.sync_service.get_latest_run, side_effect=error),
|
||||
)
|
||||
handler = unwrap(WorkspaceLatestIMSyncRunApi.get)
|
||||
request_context = app.test_request_context(method="GET")
|
||||
handler_args = (WorkspaceLatestIMSyncRunApi(), "workspace-1")
|
||||
@@ -823,7 +864,7 @@ def test_contact_read_handlers_preserve_current_and_editor_safe_projections(
|
||||
assert options["data"][0]["im_providers"] == ["feishu"]
|
||||
assert batch_options["data"] == options["data"]
|
||||
assert "im_bindings" not in options["data"][0]
|
||||
assert "email" not in batch["data"][0]
|
||||
assert batch["data"][0]["email"] == "reviewer@example.com"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider", list(IMProvider))
|
||||
|
||||
@@ -5,7 +5,9 @@ import logging
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from contextlib import contextmanager
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from functools import partial
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
@@ -18,7 +20,7 @@ from pytest_mock import MockerFixture
|
||||
from sqlalchemy import Engine as _app_generator_Engine
|
||||
from sqlalchemy import event as sqlalchemy_event
|
||||
from sqlalchemy.engine import Engine as _advanced_chat_app_generator_Engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
import core.app.apps.advanced_chat.app_generator as app_generator_module
|
||||
import core.workflow.nodes.human_input.entities # noqa: F401
|
||||
@@ -31,6 +33,7 @@ from core.app.apps.advanced_chat.generate_task_pipeline import (
|
||||
MessageSnapshot,
|
||||
WorkflowSnapshot,
|
||||
)
|
||||
from core.app.apps.base_app_generator import noop_restore_file_access
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
from core.app.apps.workflow import app_generator as wf_app_gen_module
|
||||
from core.app.entities.app_invoke_entities import (
|
||||
@@ -43,7 +46,10 @@ from core.app.file_access import DatabaseFileAccessController
|
||||
from core.app.task_pipeline import message_cycle_manager
|
||||
from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
|
||||
from core.app.workflow.file_runtime import DifyWorkflowFileRuntime
|
||||
from core.human_input_v2.resolved_form import ResolvedForm
|
||||
from core.human_input_v2.shared.values import RecipientId, TenantId
|
||||
from core.workflow import node_factory as node_factory_module
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
from core.workflow.node_factory import DifyNodeFactory
|
||||
from core.workflow.system_variables import build_system_variables
|
||||
from graphon.engine import Engine as _pause_resume_Engine
|
||||
@@ -59,6 +65,7 @@ from graphon.entities import WorkflowStartReason
|
||||
from graphon.entities.base_node_data import BaseNodeData, RetryConfig
|
||||
from graphon.entities.pause_reason import SchedulingPause
|
||||
from graphon.enums import BuiltinNodeTypes, NodeType, WorkflowNodeExecutionStatus
|
||||
from graphon.file import File, FileTransferMethod, FileType
|
||||
from graphon.graph import Graph
|
||||
from graphon.node_events import NodeRunResult, PauseRequestedEvent
|
||||
from graphon.nodes.base.entities import OutputVariableEntity
|
||||
@@ -67,6 +74,7 @@ from graphon.nodes.end.entities import EndNodeData
|
||||
from graphon.nodes.start.entities import StartNodeData
|
||||
from graphon.runtime import RuntimeState, VariablePool
|
||||
from graphon.variables.input_entities import VariableEntity
|
||||
from graphon.variables.segments import FileSegment
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models import Account as _advanced_chat_app_generator_Account
|
||||
from models import Workflow as _advanced_chat_app_generator_Workflow
|
||||
@@ -77,7 +85,10 @@ from models.model import App, AppMode, Conversation, EndUser, Message
|
||||
from models.workflow import Workflow as _app_generator_Workflow
|
||||
from models.workflow import Workflow as _pause_resume_Workflow
|
||||
from models.workflow import WorkflowType
|
||||
from repositories.human_input_v2.form_repository import Form, FormCreateParams
|
||||
from repositories.human_input_v2.sqlalchemy_form_repository import SQLAlchemyFormRepository
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
from services.human_input_v2.submission_service import HumanInputSubmissionService
|
||||
from services.workflow_run_agg import WorkflowRunAgg
|
||||
from tests.helpers.unit.config_override import apply_config_overrides
|
||||
from tests.helpers.unit.model_factories import make_upload_file
|
||||
@@ -671,9 +682,15 @@ class TestAdvancedChatAppGeneratorInternals:
|
||||
assert isinstance(captured["conversation"], ConversationSnapshot)
|
||||
assert isinstance(captured["message"], MessageSnapshot)
|
||||
|
||||
@pytest.mark.parametrize("resuming", [False, True], ids=["file-default", "submitted-hitl-file"])
|
||||
def test_generate_internal_flow_with_existing_records_skips_init(
|
||||
self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: _app_generator_Engine
|
||||
):
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
sqlite_engine: _app_generator_Engine,
|
||||
resuming: bool,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
generator = AdvancedChatAppGenerator(execution_driver=WorkflowRunAgg.run)
|
||||
generator._dialogue_count = 0
|
||||
app_config = self._build_app_config()
|
||||
@@ -688,6 +705,40 @@ class TestAdvancedChatAppGeneratorInternals:
|
||||
}
|
||||
)
|
||||
]
|
||||
if resuming:
|
||||
app_config.variables = []
|
||||
now = naive_utc_now()
|
||||
repository = SQLAlchemyFormRepository(sqlite_session, TenantId(app_config.tenant_id), app_config.app_id)
|
||||
form = repository.create_form(
|
||||
FormCreateParams(
|
||||
workflow_run_id="run-id",
|
||||
node_execution_id="approval-execution",
|
||||
resolved_form=ResolvedForm(title="Approval", parts=(), actions=(), legacy_form_content=""),
|
||||
expiration_time=now + timedelta(hours=1),
|
||||
global_timeout_deadline=now + timedelta(days=1),
|
||||
)
|
||||
)
|
||||
accepted = repository.submit_form(
|
||||
form.id,
|
||||
submitted_by_recipient_id=RecipientId("recipient"),
|
||||
submitted_by_delivery_id="delivery",
|
||||
selected_action_id="approve",
|
||||
raw_submission_inputs={},
|
||||
normalized_submission_data={
|
||||
"file": FileSegment(
|
||||
value=File(
|
||||
file_type=FileType.DOCUMENT,
|
||||
transfer_method=FileTransferMethod.LOCAL_FILE,
|
||||
reference=build_file_reference(record_id=default_file.id),
|
||||
filename=default_file.name,
|
||||
extension=".txt",
|
||||
mime_type=default_file.mime_type,
|
||||
size=default_file.size,
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
assert isinstance(accepted, Form)
|
||||
file_runtime = DifyWorkflowFileRuntime(file_access_controller=DatabaseFileAccessController())
|
||||
monkeypatch.setattr(file_runtime, "_secret_key", lambda: b"test-key")
|
||||
|
||||
@@ -780,6 +831,16 @@ class TestAdvancedChatAppGeneratorInternals:
|
||||
conversation=conversation,
|
||||
message=message,
|
||||
stream=False,
|
||||
graph_runtime_state=_build_runtime_state("run-id") if resuming else None,
|
||||
restore_file_access=(
|
||||
partial(
|
||||
HumanInputSubmissionService(session_factory=sqlite_session_factory).grant_submitted_file_access,
|
||||
app_id=app_config.app_id,
|
||||
workflow_run_id="run-id",
|
||||
)
|
||||
if resuming
|
||||
else noop_restore_file_access
|
||||
),
|
||||
)
|
||||
|
||||
assert response == {"raw": True}
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
import contextlib
|
||||
import contextvars
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
@@ -31,22 +33,31 @@ from core.app.layers.pause_state_persist_layer import (
|
||||
_WorkflowGenerateEntityWrapper,
|
||||
)
|
||||
from core.app.workflow.file_runtime import DifyWorkflowFileRuntime
|
||||
from core.human_input_v2.resolved_form import ResolvedForm
|
||||
from core.human_input_v2.shared.values import RecipientId, TenantId
|
||||
from core.repositories import SQLAlchemyWorkflowExecutionRepository, SQLAlchemyWorkflowNodeExecutionRepository
|
||||
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
|
||||
from core.tools.tool_file_manager import ToolFileManager
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
from core.workflow.node_runtime import resolve_dify_run_context
|
||||
from core.workflow.system_variables import SystemVariableKey, get_system_text
|
||||
from graphon.engine_events import GraphRunSucceededEvent
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from graphon.file import File, FileTransferMethod, FileType
|
||||
from graphon.runtime import RuntimeState, VariablePool
|
||||
from graphon.variable_loader import DUMMY_VARIABLE_LOADER
|
||||
from graphon.variables.input_entities import VariableEntity
|
||||
from graphon.variables.segments import ArrayFileSegment, FileSegment
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.enums import CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom
|
||||
from models.model import App, AppMode, EndUser
|
||||
from models.snippet import CustomizedSnippet
|
||||
from models.workflow import Workflow, WorkflowKind, WorkflowNodeExecutionTriggeredFrom, WorkflowRun, WorkflowType
|
||||
from repositories.entities.workflow_execution import WorkflowExecutionIdentity
|
||||
from repositories.human_input_v2.form_repository import Form, FormCreateParams
|
||||
from repositories.human_input_v2.sqlalchemy_form_repository import SQLAlchemyFormRepository
|
||||
from services.file_request_service import FileRequestService
|
||||
from services.human_input_v2.submission_service import HumanInputSubmissionService
|
||||
from services.workflow_run_agg import WorkflowRunAgg
|
||||
from tests.helpers.unit.model_factories import make_account, make_upload_file, make_workflow
|
||||
|
||||
@@ -195,6 +206,140 @@ class TestWorkflowAppGeneratorCases:
|
||||
def disable_enterprise_telemetry(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("core.telemetry.gateway.is_enterprise_telemetry_enabled", lambda: False)
|
||||
|
||||
@staticmethod
|
||||
@pytest.mark.parametrize("file_list", [False, True], ids=["single-file", "file-list"])
|
||||
@pytest.mark.parametrize("invoke_from", [InvokeFrom.WEB_APP, InvokeFrom.SERVICE_API])
|
||||
def test_resumed_end_user_can_output_accepted_hitl_files(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
file_list: bool,
|
||||
invoke_from: InvokeFrom,
|
||||
) -> None:
|
||||
app, workflow, end_user = _persist_generator_rows(sqlite_session)
|
||||
uploaded = make_upload_file(tenant_id=app.tenant_id, created_by="approver")
|
||||
unrelated = make_upload_file(tenant_id=app.tenant_id, created_by="approver")
|
||||
workflow.graph = json.dumps(
|
||||
{
|
||||
"nodes": [
|
||||
{"id": "start", "data": {"type": "start", "title": "Start", "variables": []}},
|
||||
{
|
||||
"id": "end",
|
||||
"data": {
|
||||
"type": "end",
|
||||
"title": "End",
|
||||
"outputs": [{"variable": "result", "value_selector": ["approval", "file"]}],
|
||||
},
|
||||
},
|
||||
],
|
||||
"edges": [{"source": "start", "target": "end"}],
|
||||
}
|
||||
)
|
||||
sqlite_session.add_all(
|
||||
[
|
||||
uploaded,
|
||||
unrelated,
|
||||
WorkflowRun(
|
||||
id="run",
|
||||
tenant_id=app.tenant_id,
|
||||
app_id=app.id,
|
||||
workflow_id=workflow.id,
|
||||
type=workflow.type,
|
||||
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
|
||||
version=workflow.version,
|
||||
graph=workflow.graph,
|
||||
inputs="{}",
|
||||
status=WorkflowExecutionStatus.PAUSED,
|
||||
created_by_role=CreatorUserRole.END_USER,
|
||||
created_by=end_user.id,
|
||||
),
|
||||
]
|
||||
)
|
||||
sqlite_session.commit()
|
||||
file = File(
|
||||
file_type=FileType.DOCUMENT,
|
||||
transfer_method=FileTransferMethod.LOCAL_FILE,
|
||||
reference=build_file_reference(record_id=uploaded.id),
|
||||
filename=uploaded.name,
|
||||
extension=".txt",
|
||||
mime_type=uploaded.mime_type,
|
||||
size=uploaded.size,
|
||||
)
|
||||
submitted = ArrayFileSegment(value=[file]) if file_list else FileSegment(value=file)
|
||||
now = naive_utc_now()
|
||||
with sqlite_session_factory.begin() as session:
|
||||
repository = SQLAlchemyFormRepository(session, TenantId(app.tenant_id), app.id)
|
||||
form = repository.create_form(
|
||||
FormCreateParams(
|
||||
workflow_run_id="run",
|
||||
node_execution_id="approval-execution",
|
||||
resolved_form=ResolvedForm(title="Approval", parts=(), actions=(), legacy_form_content=""),
|
||||
expiration_time=now + timedelta(hours=1),
|
||||
global_timeout_deadline=now + timedelta(days=1),
|
||||
)
|
||||
)
|
||||
accepted = repository.submit_form(
|
||||
form.id,
|
||||
submitted_by_recipient_id=RecipientId("recipient"),
|
||||
submitted_by_delivery_id="delivery",
|
||||
selected_action_id="approve",
|
||||
raw_submission_inputs={},
|
||||
normalized_submission_data={"file": submitted},
|
||||
)
|
||||
assert isinstance(accepted, Form)
|
||||
state = RuntimeState(workflow_id=workflow.id, variable_pool=VariablePool(), start_at=0)
|
||||
state.variable_pool.add(["approval", "file"], submitted)
|
||||
state.variable_pool.add(["sys", "workflow_run_id"], "run")
|
||||
state.variable_pool.add(["sys", "app_id"], app.id)
|
||||
file_runtime = DifyWorkflowFileRuntime(file_access_controller=DatabaseFileAccessController())
|
||||
monkeypatch.setattr(file_runtime, "_secret_key", lambda: b"test-key")
|
||||
completed: list[GraphRunSucceededEvent] = []
|
||||
|
||||
def execute(
|
||||
runner: WorkflowBasedAppRunner,
|
||||
_pause_config: PauseStateLayerConfig | None,
|
||||
_executions: WorkflowExecutionRepository,
|
||||
_nodes: WorkflowNodeExecutionRepository,
|
||||
) -> None:
|
||||
prepared = runner.prepare()
|
||||
assert prepared is not None
|
||||
events = list(prepared.entry.run())
|
||||
completed.extend(event for event in events if isinstance(event, GraphRunSucceededEvent))
|
||||
|
||||
def respond(**_kwargs: object) -> dict[str, object]:
|
||||
assert file_runtime.resolve_upload_file_uri(upload_file_id=uploaded.id).startswith(
|
||||
f"/files/{uploaded.id}/file-preview?"
|
||||
)
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
file_runtime.resolve_upload_file_uri(upload_file_id=unrelated.id)
|
||||
return {}
|
||||
|
||||
generator = WorkflowAppGenerator(execution_driver=execute)
|
||||
monkeypatch.setattr(generator, "_handle_response", respond)
|
||||
monkeypatch.setattr(
|
||||
_workflow_app_generator.WorkflowAppGenerateResponseConverter,
|
||||
"convert",
|
||||
lambda response, invoke_from: response,
|
||||
)
|
||||
execution_repository, node_repository = _repositories(sqlite_session_factory, app, end_user)
|
||||
generator.resume(
|
||||
app_model=app,
|
||||
workflow=workflow,
|
||||
user=end_user,
|
||||
application_generate_entity=_generate_entity(app, workflow, end_user, invoke_from=invoke_from),
|
||||
graph_runtime_state=state,
|
||||
workflow_execution_repository=execution_repository,
|
||||
workflow_node_execution_repository=node_repository,
|
||||
restore_file_access=partial(
|
||||
HumanInputSubmissionService(session_factory=sqlite_session_factory).grant_submitted_file_access,
|
||||
app_id=app.id,
|
||||
workflow_run_id="run",
|
||||
),
|
||||
)
|
||||
|
||||
assert len(completed) == 1
|
||||
assert completed[0].outputs["result"] == submitted.value
|
||||
|
||||
@staticmethod
|
||||
def test_worker_executes_and_persists_a_workflow_through_the_injected_driver(
|
||||
app: Flask, sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]
|
||||
|
||||
@@ -28,6 +28,7 @@ from core.human_input_v2.im_integration import (
|
||||
SyncReconciler,
|
||||
)
|
||||
from core.human_input_v2.im_integration.adapters import DirectoryEntry, ProviderUserId
|
||||
from core.human_input_v2.im_integration.sync_records import AccountIMSyncInitiator
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
@@ -114,7 +115,7 @@ def test_sync_run_captures_complete_channel_revision() -> None:
|
||||
sync_run_id=IMSyncRunId("run-1"),
|
||||
channel_revision=_RUN.channel_revision,
|
||||
provider=IMProvider.FEISHU,
|
||||
started_by_account_id=AccountId("account-1"),
|
||||
started_by=AccountIMSyncInitiator(AccountId("account-1")),
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
|
||||
+2
-1
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from core.human_input_v2.entities import IMIdentityBindingStatus, IMProvider, IMSyncResultType
|
||||
from core.human_input_v2.im_integration import IMChannelRevision, IMSyncRun, SyncResultFact
|
||||
from core.human_input_v2.im_integration.sync_records import SysUserId, SysUserIMSyncInitiator
|
||||
from core.human_input_v2.shared import ContactId, IMIdentityId, IMSyncResultId, IMSyncRunId
|
||||
from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials
|
||||
from repositories.human_input_v2.im_binding_repository import IMBindingAssignment
|
||||
@@ -61,7 +62,7 @@ def _run(run_id: IMSyncRunId, created_at: datetime) -> IMSyncRun:
|
||||
sync_run_id=run_id,
|
||||
channel_revision=IMChannelRevision(str(_CHANNEL_ID), 1),
|
||||
provider=IMProvider.FEISHU,
|
||||
started_by_account_id=None,
|
||||
started_by=SysUserIMSyncInitiator(SysUserId("00000000-0000-0000-0000-000000000999")),
|
||||
now=created_at,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Bidirectional mapping tests for every IM Control Plane record."""
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
@@ -23,6 +24,12 @@ from core.human_input_v2.im_integration import (
|
||||
SyncResultFact,
|
||||
)
|
||||
from core.human_input_v2.im_integration.adapters import ProviderUserId
|
||||
from core.human_input_v2.im_integration.sync_records import (
|
||||
AccountIMSyncInitiator,
|
||||
IMSyncInitiator,
|
||||
SysUserId,
|
||||
SysUserIMSyncInitiator,
|
||||
)
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
@@ -52,7 +59,7 @@ def _run() -> IMSyncRun:
|
||||
sync_run_id=IMSyncRunId("run-1"),
|
||||
channel_revision=IMChannelRevision(str(_CHANNEL_ID), 1),
|
||||
provider=IMProvider.FEISHU,
|
||||
started_by_account_id=AccountId("account-1"),
|
||||
started_by=AccountIMSyncInitiator(AccountId("account-1")),
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
@@ -94,12 +101,30 @@ def _result() -> SyncResultFact:
|
||||
)
|
||||
|
||||
|
||||
def test_sync_run_mapping_round_trips_captured_revision_and_counts() -> None:
|
||||
run = _run()
|
||||
@pytest.mark.parametrize(
|
||||
"started_by",
|
||||
[AccountIMSyncInitiator(AccountId("account-1")), SysUserIMSyncInitiator(SysUserId("sys-user-1"))],
|
||||
)
|
||||
def test_sync_run_mapping_round_trips_captured_revision_counts_and_initiator(started_by: IMSyncInitiator) -> None:
|
||||
run = replace(_run(), started_by=started_by)
|
||||
|
||||
assert sync_run_from_record(sync_run_to_record(run)) == run
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("account_id", "sys_user_id"),
|
||||
[(None, None), ("account-1", "sys-user-1")],
|
||||
ids=["missing-initiator", "multiple-initiators"],
|
||||
)
|
||||
def test_sync_run_mapping_rejects_invalid_initiator_columns(account_id: str | None, sys_user_id: str | None) -> None:
|
||||
record = sync_run_to_record(_run())
|
||||
record.started_by_account_id = account_id
|
||||
record.started_by_sys_user_id = sys_user_id
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
sync_run_from_record(record)
|
||||
|
||||
|
||||
def test_sync_result_mapping_round_trips_all_structured_snapshots() -> None:
|
||||
result = _result()
|
||||
|
||||
|
||||
@@ -4,13 +4,16 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine, func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.human_input_v2.entities import IMProvider, IMSyncResultType
|
||||
from core.human_input_v2.im_integration import IMChannelRevision, IMSyncRun, SyncResultFact
|
||||
from core.human_input_v2.im_integration.sync_records import SysUserId, SysUserIMSyncInitiator
|
||||
from core.human_input_v2.shared import IMSyncResultId, IMSyncRunId
|
||||
from models.human_input_v2 import HumanInputIMSyncResult
|
||||
from models.human_input_v2 import HumanInputIMSyncResult, HumanInputIMSyncRun
|
||||
from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials
|
||||
from repositories.human_input_v2.im_channel_repository import (
|
||||
IMChannel,
|
||||
@@ -44,7 +47,7 @@ def _run() -> IMSyncRun:
|
||||
sync_run_id=IMSyncRunId("00000000-0000-0000-0000-000000000201"),
|
||||
channel_revision=IMChannelRevision(str(_CHANNEL_ID), 1),
|
||||
provider=IMProvider.FEISHU,
|
||||
started_by_account_id=None,
|
||||
started_by=SysUserIMSyncInitiator(SysUserId("00000000-0000-0000-0000-000000000999")),
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
@@ -88,3 +91,29 @@ def test_diagnostic_append_uses_the_supplied_session_transaction(sqlite_engine:
|
||||
|
||||
with Session(sqlite_engine) as session:
|
||||
assert session.scalar(select(func.count(HumanInputIMSyncResult.id))) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("account_id", "sys_user_id"),
|
||||
[
|
||||
(None, None),
|
||||
("00000000-0000-0000-0000-000000000501", "00000000-0000-0000-0000-000000000601"),
|
||||
],
|
||||
ids=["missing-initiator", "multiple-initiators"],
|
||||
)
|
||||
def test_sync_run_requires_exactly_one_initiator(
|
||||
sqlite_engine: Engine, account_id: str | None, sys_user_id: str | None
|
||||
) -> None:
|
||||
with Session(sqlite_engine) as session:
|
||||
session.add(
|
||||
HumanInputIMSyncRun(
|
||||
channel_id=str(_CHANNEL_ID),
|
||||
channel_config_version=1,
|
||||
provider=IMProvider.FEISHU,
|
||||
status=_run().status,
|
||||
started_by_account_id=account_id,
|
||||
started_by_sys_user_id=sys_user_id,
|
||||
)
|
||||
)
|
||||
with pytest.raises(IntegrityError, match="initiator_exclusive"):
|
||||
session.flush()
|
||||
+15
-10
@@ -28,6 +28,7 @@ from core.human_input_v2.im_integration.adapters import (
|
||||
ProviderUserId,
|
||||
)
|
||||
from core.human_input_v2.im_integration.adapters.entities import Avatar
|
||||
from core.human_input_v2.im_integration.sync_records import AccountIMSyncInitiator
|
||||
from core.human_input_v2.shared import AccountId, ContactId, IMSyncRunId, TenantId
|
||||
from extensions.ext_storage import storage
|
||||
from models.account import Account, TenantAccountJoin, TenantAccountRole
|
||||
@@ -187,7 +188,7 @@ def reconciliation_context(sqlite_engine: Engine) -> _ReconciliationContext:
|
||||
WorkspaceIMChannelWriter(session, _TENANT_ID, _ACCOUNT_ID).create(channel)
|
||||
decision = SQLAlchemyIMControlPlaneRepository(session, channel).create_or_get_active_run(
|
||||
sync_run_id=_RUN_ID,
|
||||
started_by_account_id=_ACCOUNT_ID,
|
||||
started_by=AccountIMSyncInitiator(_ACCOUNT_ID),
|
||||
now=_NOW,
|
||||
)
|
||||
assert decision.run is not None
|
||||
@@ -214,17 +215,17 @@ def _create_active_run(
|
||||
with sessions.begin() as session:
|
||||
decision = SQLAlchemyIMControlPlaneRepository(session, channel).create_or_get_active_run(
|
||||
sync_run_id=sync_run_id,
|
||||
started_by_account_id=_ACCOUNT_ID,
|
||||
started_by=AccountIMSyncInitiator(_ACCOUNT_ID),
|
||||
now=_NOW,
|
||||
)
|
||||
assert decision.run is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("has_initiator", [True, False])
|
||||
def test_workspace_sync_stores_avatar_with_initiator_or_owner(
|
||||
@pytest.mark.parametrize("has_account_initiator", [True, False])
|
||||
def test_workspace_sync_stores_avatar_with_account_initiator_or_owner(
|
||||
reconciliation_context: _ReconciliationContext,
|
||||
mocker: MockerFixture,
|
||||
has_initiator: bool,
|
||||
has_account_initiator: bool,
|
||||
) -> None:
|
||||
save = mocker.patch.object(storage, "save")
|
||||
with reconciliation_context.sessions.begin() as session:
|
||||
@@ -241,9 +242,11 @@ def test_workspace_sync_stores_avatar_with_initiator_or_owner(
|
||||
),
|
||||
]
|
||||
)
|
||||
if not has_initiator:
|
||||
session.get_one(HumanInputIMSyncRun, str(_RUN_ID)).started_by_account_id = None
|
||||
expected_creator = str(_ACCOUNT_ID) if has_initiator else owner.id
|
||||
if not has_account_initiator:
|
||||
run_record = session.get_one(HumanInputIMSyncRun, str(_RUN_ID))
|
||||
run_record.started_by_account_id = None
|
||||
run_record.started_by_sys_user_id = "00000000-0000-0000-0000-000000000999"
|
||||
expected_creator = str(_ACCOUNT_ID) if has_account_initiator else owner.id
|
||||
entry = replace(_successful_directory().entries[0], avatar=Avatar(mime_type="image/png", data=b"first avatar"))
|
||||
service = reconciliation_context.service(_Adapter(Directory((entry,))))
|
||||
|
||||
@@ -295,7 +298,9 @@ def test_missing_workspace_owner_fails_before_storing_avatars(
|
||||
) -> None:
|
||||
save = mocker.patch.object(storage, "save")
|
||||
with reconciliation_context.sessions.begin() as session:
|
||||
session.get_one(HumanInputIMSyncRun, str(_RUN_ID)).started_by_account_id = None
|
||||
run_record = session.get_one(HumanInputIMSyncRun, str(_RUN_ID))
|
||||
run_record.started_by_account_id = None
|
||||
run_record.started_by_sys_user_id = "00000000-0000-0000-0000-000000000999"
|
||||
entry = replace(_successful_directory().entries[0], avatar=Avatar(mime_type="image/png", data=b"avatar"))
|
||||
|
||||
run = reconciliation_context.service(_Adapter(Directory((entry,)))).reconcile(_RUN_ID)
|
||||
@@ -726,7 +731,7 @@ def test_trigger_retires_abandoned_old_revision_and_creates_current_run(
|
||||
with reconciliation_context.sessions.begin() as session:
|
||||
repository = SQLAlchemyIMControlPlaneRepository(session, current_channel)
|
||||
decision = repository.create_or_get_active_run(
|
||||
sync_run_id=_SECOND_RUN_ID, started_by_account_id=_ACCOUNT_ID, now=_NOW
|
||||
sync_run_id=_SECOND_RUN_ID, started_by=AccountIMSyncInitiator(_ACCOUNT_ID), now=_NOW
|
||||
)
|
||||
assert decision.run is not None
|
||||
assert decision.run.id == _SECOND_RUN_ID
|
||||
|
||||
@@ -27,6 +27,7 @@ from core.human_input_v2.im_integration.adapters import (
|
||||
SlackCredentials,
|
||||
)
|
||||
from core.human_input_v2.im_integration.adapters.credentials import FeishuCredentials, IMProviderCredentials
|
||||
from core.human_input_v2.im_integration.sync_records import SysUserId, SysUserIMSyncInitiator
|
||||
from core.human_input_v2.shared import AccountId, ContactId, DeploymentScope, IMSyncRunId, TenantId, WorkspaceScope
|
||||
from libs.uuid_utils import uuidv7
|
||||
from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials
|
||||
@@ -225,7 +226,7 @@ def _seed_run(
|
||||
DeploymentIMChannelWriter(session).create(channel)
|
||||
decision = SQLAlchemyIMControlPlaneRepository(session, channel).create_or_get_active_run(
|
||||
sync_run_id=_RUN_ID,
|
||||
started_by_account_id=None,
|
||||
started_by=SysUserIMSyncInitiator(SysUserId("00000000-0000-0000-0000-000000000999")),
|
||||
now=_NOW,
|
||||
)
|
||||
assert decision.run is not None
|
||||
|
||||
@@ -10,6 +10,7 @@ import pytest
|
||||
|
||||
from core.human_input_v2.entities import IMProvider, IMSyncRunStatus
|
||||
from core.human_input_v2.im_integration import IMChannelRevision, IMSyncRun
|
||||
from core.human_input_v2.im_integration.sync_records import SysUserId, SysUserIMSyncInitiator
|
||||
from core.human_input_v2.shared import IMSyncRunId, TenantId, WorkspaceScope
|
||||
from services.human_input_v2.im_contact_sync.coordinator import IMChannelReconciliationService, IMSyncRetryableError
|
||||
from services.human_input_v2.im_contact_sync.service import IMSyncService
|
||||
@@ -25,7 +26,7 @@ def _queued_run() -> IMSyncRun:
|
||||
sync_run_id=IMSyncRunId("run-1"),
|
||||
channel_revision=IMChannelRevision("channel-1", 1),
|
||||
provider=IMProvider.FEISHU,
|
||||
started_by_account_id=None,
|
||||
started_by=SysUserIMSyncInitiator(SysUserId("00000000-0000-0000-0000-000000000999")),
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
|
||||
@@ -13,9 +13,16 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.human_input_v2.entities import IMProvider, IMSyncResultType, IMSyncRunStatus
|
||||
from core.human_input_v2.im_integration import SyncResultFact
|
||||
from core.human_input_v2.im_integration.sync_records import (
|
||||
AccountIMSyncInitiator,
|
||||
IMSyncInitiator,
|
||||
SysUserId,
|
||||
SysUserIMSyncInitiator,
|
||||
)
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
DeploymentScope,
|
||||
DirectoryScope,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
@@ -24,7 +31,9 @@ from core.human_input_v2.shared import (
|
||||
TenantId,
|
||||
WorkspaceScope,
|
||||
)
|
||||
from models.account import Account
|
||||
from models.human_input_v2 import HumanInputIMSyncRun
|
||||
from repositories.account.repository import SQLAlchemyAccountRepository
|
||||
from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials
|
||||
from repositories.human_input_v2.im_binding_repository import IMBindingAssignment
|
||||
from repositories.human_input_v2.im_channel_repository import (
|
||||
@@ -37,6 +46,7 @@ from repositories.human_input_v2.im_identity_repository import IMIdentityObserva
|
||||
from repositories.human_input_v2.im_integration.mappers import sync_result_to_record
|
||||
from repositories.human_input_v2.sqlalchemy_im_binding_repository import SQLAlchemyIMBindingRepository
|
||||
from repositories.human_input_v2.sqlalchemy_im_channel_repository import (
|
||||
DeploymentIMChannelWriter,
|
||||
WorkspaceIMChannelReader,
|
||||
WorkspaceIMChannelWriter,
|
||||
)
|
||||
@@ -90,6 +100,7 @@ class _ServiceContext:
|
||||
self.sessions,
|
||||
resolve_channel,
|
||||
lambda run_id, scope: self.dispatched.append((run_id, scope)),
|
||||
account_query=SQLAlchemyAccountRepository(self.sessions),
|
||||
clock=lambda: _NOW,
|
||||
run_id_factory=lambda: _RUN_ID,
|
||||
)
|
||||
@@ -108,17 +119,17 @@ def test_created_or_existing_active_run_is_serialized_and_dispatched_by_state(
|
||||
) -> None:
|
||||
service = service_context.service()
|
||||
|
||||
created = service.create_or_get_active_run(_SCOPE, _ACCOUNT_ID)
|
||||
retried = service.create_or_get_active_run(_SCOPE, _ACCOUNT_ID)
|
||||
created = service.create_or_get_active_run(_SCOPE, AccountIMSyncInitiator(_ACCOUNT_ID))
|
||||
retried = service.create_or_get_active_run(_SCOPE, AccountIMSyncInitiator(_ACCOUNT_ID))
|
||||
|
||||
assert created == retried
|
||||
assert created.channel_revision.channel_id == str(_CHANNEL_ID)
|
||||
assert created.run.channel_revision.channel_id == str(_CHANNEL_ID)
|
||||
assert service_context.dispatched == [(_RUN_ID, _SCOPE), (_RUN_ID, _SCOPE)]
|
||||
with service_context.sessions.begin() as session:
|
||||
session.get_one(HumanInputIMSyncRun, str(_RUN_ID)).status = IMSyncRunStatus.RUNNING
|
||||
|
||||
running = service.create_or_get_active_run(_SCOPE, _ACCOUNT_ID)
|
||||
assert running.status is IMSyncRunStatus.RUNNING
|
||||
running = service.create_or_get_active_run(_SCOPE, AccountIMSyncInitiator(_ACCOUNT_ID))
|
||||
assert running.run.status is IMSyncRunStatus.RUNNING
|
||||
assert service_context.dispatched == [(_RUN_ID, _SCOPE), (_RUN_ID, _SCOPE)]
|
||||
with service_context.sessions() as session:
|
||||
assert session.scalar(sa.select(sa.func.count(HumanInputIMSyncRun.id))) == 1
|
||||
@@ -139,12 +150,13 @@ def test_dispatch_failure_occurs_after_queued_run_is_committed(
|
||||
service_context.sessions,
|
||||
resolve_channel,
|
||||
fail_dispatch,
|
||||
account_query=SQLAlchemyAccountRepository(service_context.sessions),
|
||||
clock=lambda: _NOW,
|
||||
run_id_factory=lambda: _RUN_ID,
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.ERROR), pytest.raises(IMSyncDispatchUnavailableError) as error_info:
|
||||
service.create_or_get_active_run(_SCOPE, _ACCOUNT_ID)
|
||||
service.create_or_get_active_run(_SCOPE, AccountIMSyncInitiator(_ACCOUNT_ID))
|
||||
|
||||
assert isinstance(error_info.value.__cause__, ConnectionError)
|
||||
assert str(_RUN_ID) in caplog.text
|
||||
@@ -153,14 +165,19 @@ def test_dispatch_failure_occurs_after_queued_run_is_committed(
|
||||
|
||||
|
||||
def test_missing_or_stale_channel_fails_before_run_creation(service_context: _ServiceContext) -> None:
|
||||
missing = IMSyncService(service_context.sessions, lambda _session, _scope: None, lambda _id, _scope: None)
|
||||
missing = IMSyncService(
|
||||
service_context.sessions,
|
||||
lambda _session, _scope: None,
|
||||
lambda _id, _scope: None,
|
||||
account_query=SQLAlchemyAccountRepository(service_context.sessions),
|
||||
)
|
||||
with pytest.raises(IMChannelNotConfiguredError):
|
||||
missing.create_or_get_active_run(_SCOPE, _ACCOUNT_ID)
|
||||
missing.create_or_get_active_run(_SCOPE, AccountIMSyncInitiator(_ACCOUNT_ID))
|
||||
|
||||
with pytest.raises(IMSyncRevisionChangedError):
|
||||
service_context.service(channel_override=replace(_channel(), config_version=2)).create_or_get_active_run(
|
||||
_SCOPE,
|
||||
_ACCOUNT_ID,
|
||||
AccountIMSyncInitiator(_ACCOUNT_ID),
|
||||
)
|
||||
|
||||
with service_context.sessions() as session:
|
||||
@@ -169,7 +186,8 @@ def test_missing_or_stale_channel_fails_before_run_creation(service_context: _Se
|
||||
|
||||
def test_latest_result_and_identity_queries_use_current_channel(service_context: _ServiceContext) -> None:
|
||||
service = service_context.service()
|
||||
run = service.create_or_get_active_run(_SCOPE, _ACCOUNT_ID)
|
||||
summary = service.create_or_get_active_run(_SCOPE, AccountIMSyncInitiator(_ACCOUNT_ID))
|
||||
run = summary.run
|
||||
with service_context.sessions.begin() as session:
|
||||
identities = SQLAlchemyIMIdentityRepository(session, _CHANNEL_ID)
|
||||
identities.create(
|
||||
@@ -218,7 +236,7 @@ def test_latest_result_and_identity_queries_use_current_channel(service_context:
|
||||
)
|
||||
)
|
||||
|
||||
assert service.get_latest_run(_SCOPE) == run
|
||||
assert service.get_latest_run(_SCOPE) == summary
|
||||
result_page = service.list_latest_results(_SCOPE, IMSyncResultType.NOT_MATCHED, page=1, limit=20)
|
||||
identity_page = service.search_identities(_SCOPE, keyword="REVIEWER", page=1, limit=20)
|
||||
assert [result.id for result in result_page.items] == [IMSyncResultId("00000000-0000-0000-0000-000000000701")]
|
||||
@@ -229,3 +247,75 @@ def test_latest_result_and_identity_queries_use_current_channel(service_context:
|
||||
def test_latest_queries_fail_closed_without_a_current_run(service_context: _ServiceContext) -> None:
|
||||
with pytest.raises(IMSyncRunNotFoundError):
|
||||
service_context.service().get_latest_run(_SCOPE)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("owner_scope", "started_by", "account_id", "sys_user_id", "display_name"),
|
||||
[
|
||||
(_SCOPE, AccountIMSyncInitiator(_ACCOUNT_ID), _ACCOUNT_ID, None, "Workspace Owner"),
|
||||
(DeploymentScope(), SysUserIMSyncInitiator(SysUserId(str(_ACCOUNT_ID))), None, str(_ACCOUNT_ID), ""),
|
||||
],
|
||||
ids=["ce-saas-account", "ee-administrator"],
|
||||
)
|
||||
def test_sync_run_preserves_initiating_identity_when_another_caller_reuses_it(
|
||||
sqlite_engine: Engine,
|
||||
owner_scope: DirectoryScope,
|
||||
started_by: IMSyncInitiator,
|
||||
account_id: AccountId | None,
|
||||
sys_user_id: str | None,
|
||||
display_name: str,
|
||||
) -> None:
|
||||
sessions = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
channel = _channel()
|
||||
with sessions.begin() as session:
|
||||
account = Account(name="Workspace Owner", email="owner@example.com")
|
||||
account.id = str(_ACCOUNT_ID)
|
||||
session.add(account)
|
||||
if isinstance(owner_scope, WorkspaceScope):
|
||||
WorkspaceIMChannelWriter(session, owner_scope.id, _ACCOUNT_ID).create(channel)
|
||||
else:
|
||||
DeploymentIMChannelWriter(session).create(channel)
|
||||
service = IMSyncService(
|
||||
sessions,
|
||||
lambda _session, _scope: channel,
|
||||
lambda _run_id, _scope: None,
|
||||
account_query=SQLAlchemyAccountRepository(sessions),
|
||||
)
|
||||
|
||||
created = service.create_or_get_active_run(owner_scope, started_by)
|
||||
next_initiator = (
|
||||
AccountIMSyncInitiator(AccountId("00000000-0000-0000-0000-000000000202"))
|
||||
if isinstance(started_by, AccountIMSyncInitiator)
|
||||
else SysUserIMSyncInitiator(SysUserId("00000000-0000-0000-0000-000000000202"))
|
||||
)
|
||||
reused = service.create_or_get_active_run(owner_scope, next_initiator)
|
||||
|
||||
assert reused == created
|
||||
assert created.initiator_display_name == display_name
|
||||
assert service.get_latest_run(owner_scope).initiator_display_name == display_name
|
||||
loaded = service.load_run(created.run.id)
|
||||
assert loaded is not None
|
||||
assert loaded.started_by == started_by
|
||||
with sessions() as session:
|
||||
stored = session.get_one(HumanInputIMSyncRun, str(created.run.id))
|
||||
assert stored.started_by_account_id == account_id
|
||||
assert stored.started_by_sys_user_id == sys_user_id
|
||||
|
||||
|
||||
def test_sync_run_returns_current_account_name(service_context: _ServiceContext) -> None:
|
||||
with service_context.sessions.begin() as session:
|
||||
account = Account(name="Original Admin", email="initiator@example.com")
|
||||
account.id = str(_ACCOUNT_ID)
|
||||
session.add(account)
|
||||
service = service_context.service()
|
||||
service.create_or_get_active_run(_SCOPE, AccountIMSyncInitiator(_ACCOUNT_ID))
|
||||
with service_context.sessions.begin() as session:
|
||||
account = session.get_one(Account, str(_ACCOUNT_ID))
|
||||
account.name = "Renamed Admin"
|
||||
|
||||
assert service.get_latest_run(_SCOPE).initiator_display_name == "Renamed Admin"
|
||||
|
||||
with service_context.sessions.begin() as session:
|
||||
session.delete(session.get_one(Account, str(_ACCOUNT_ID)))
|
||||
|
||||
assert service.get_latest_run(_SCOPE).initiator_display_name == ""
|
||||
@@ -1,17 +1,31 @@
|
||||
"""Accepted submissions retain the exact authenticated delivery across all surfaces."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import nullcontext
|
||||
from datetime import timedelta
|
||||
from hashlib import sha256
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask.testing import FlaskClient
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
||||
from core.app.file_access.controller import DatabaseFileAccessController
|
||||
from core.app.file_access.scope import FileAccessScope, bind_file_access_scope
|
||||
from core.app.workflow.file_runtime import DifyWorkflowFileRuntime
|
||||
from core.human_input_v2.entities import IMProvider
|
||||
from core.human_input_v2.resolved_form import ResolvedForm
|
||||
from core.human_input_v2.shared.values import RecipientId, TenantId
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormStatus
|
||||
from graphon.file import File, FileTransferMethod, FileType
|
||||
from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.human_input_v2 import HumanInputForm
|
||||
from repositories.human_input_v2.delivery_repository import DeliveryCreateParams
|
||||
from repositories.human_input_v2.form_repository import Form, FormCreateParams
|
||||
from repositories.human_input_v2.im_channel_repository import IMChannelId
|
||||
from repositories.human_input_v2.sqlalchemy_delivery_repository import SQLAlchemyDeliveryRepository
|
||||
from repositories.human_input_v2.sqlalchemy_form_repository import SQLAlchemyFormRepository
|
||||
@@ -24,6 +38,7 @@ from services.human_input_v2.submission_service import (
|
||||
ServiceAPIFormActor,
|
||||
WebAppFormActor,
|
||||
)
|
||||
from tests.helpers.unit.model_factories import make_upload_file
|
||||
from tests.unit_tests.services.human_input_v2.test_form_delivery_runtime import Context, PublicFormApproval
|
||||
|
||||
pytest_plugins = (
|
||||
@@ -32,6 +47,112 @@ pytest_plugins = (
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("transfer_method", [FileTransferMethod.LOCAL_FILE, FileTransferMethod.REMOTE_URL])
|
||||
@pytest.mark.parametrize(
|
||||
("form_tenant", "form_app", "form_run", "status", "accessible"),
|
||||
[
|
||||
("tenant", "app", "run", HumanInputFormStatus.SUBMITTED, True),
|
||||
("other-tenant", "app", "run", HumanInputFormStatus.SUBMITTED, False),
|
||||
("tenant", "other-app", "run", HumanInputFormStatus.SUBMITTED, False),
|
||||
("tenant", "app", "other-run", HumanInputFormStatus.SUBMITTED, False),
|
||||
("tenant", "app", "run", HumanInputFormStatus.WAITING, False),
|
||||
("tenant", "app", "run", HumanInputFormStatus.TIMEOUT, False),
|
||||
("tenant", "app", "run", HumanInputFormStatus.EXPIRED, False),
|
||||
],
|
||||
)
|
||||
def test_restored_file_access_requires_an_accepted_submission_in_the_current_run(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
transfer_method: FileTransferMethod,
|
||||
form_tenant: str,
|
||||
form_app: str,
|
||||
form_run: str,
|
||||
status: HumanInputFormStatus,
|
||||
accessible: bool,
|
||||
) -> None:
|
||||
uploaded = make_upload_file(tenant_id="tenant", created_by="approver")
|
||||
list_uploaded = make_upload_file(tenant_id="tenant", created_by="approver")
|
||||
unrelated = make_upload_file(tenant_id="tenant", created_by="approver")
|
||||
files = [
|
||||
File(
|
||||
file_type=FileType.DOCUMENT,
|
||||
transfer_method=transfer_method,
|
||||
reference=build_file_reference(record_id=record.id),
|
||||
remote_url="https://example.com/document.txt" if transfer_method == FileTransferMethod.REMOTE_URL else None,
|
||||
filename=record.name,
|
||||
extension=".txt",
|
||||
mime_type=record.mime_type,
|
||||
size=record.size,
|
||||
)
|
||||
for record in (uploaded, list_uploaded)
|
||||
]
|
||||
now = naive_utc_now()
|
||||
with sqlite_session_factory.begin() as session:
|
||||
session.add_all([uploaded, list_uploaded, unrelated])
|
||||
repository = SQLAlchemyFormRepository(session, TenantId(form_tenant), form_app)
|
||||
form = repository.create_form(
|
||||
FormCreateParams(
|
||||
workflow_run_id=form_run,
|
||||
node_execution_id=str(uuid4()),
|
||||
resolved_form=ResolvedForm(title="Approval", parts=(), actions=(), legacy_form_content=""),
|
||||
expiration_time=now + timedelta(hours=1),
|
||||
global_timeout_deadline=now + timedelta(days=1),
|
||||
)
|
||||
)
|
||||
if status == HumanInputFormStatus.SUBMITTED:
|
||||
accepted = repository.submit_form(
|
||||
form.id,
|
||||
submitted_by_recipient_id=RecipientId("recipient"),
|
||||
submitted_by_delivery_id="delivery",
|
||||
selected_action_id="approve",
|
||||
raw_submission_inputs={"untrusted_file": unrelated.id},
|
||||
normalized_submission_data={
|
||||
"single": FileSegment(value=files[0]),
|
||||
"multiple": ArrayFileSegment(value=[files[1]]),
|
||||
"empty": ArrayFileSegment(value=[]),
|
||||
"text": StringSegment(value=unrelated.id),
|
||||
},
|
||||
)
|
||||
assert isinstance(accepted, Form)
|
||||
else:
|
||||
session.get_one(HumanInputForm, form.id).status = status
|
||||
|
||||
runtime = DifyWorkflowFileRuntime(file_access_controller=DatabaseFileAccessController())
|
||||
monkeypatch.setattr(runtime, "_secret_key", lambda: b"test-key")
|
||||
scope = FileAccessScope(
|
||||
tenant_id="tenant", user_id="end-user", user_from=UserFrom.END_USER, invoke_from=InvokeFrom.WEB_APP
|
||||
)
|
||||
service = HumanInputSubmissionService(session_factory=sqlite_session_factory)
|
||||
with bind_file_access_scope(scope):
|
||||
service.grant_submitted_file_access(app_id="app", workflow_run_id="run")
|
||||
for record in (uploaded, list_uploaded):
|
||||
if accessible:
|
||||
assert runtime.resolve_upload_file_uri(upload_file_id=record.id).startswith(
|
||||
f"/files/{record.id}/file-preview?"
|
||||
)
|
||||
else:
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
runtime.resolve_upload_file_uri(upload_file_id=record.id)
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
runtime.resolve_upload_file_uri(upload_file_id=unrelated.id)
|
||||
|
||||
with bind_file_access_scope(scope), pytest.raises(ValueError, match="not found"):
|
||||
runtime.resolve_upload_file_uri(upload_file_id=uploaded.id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("account_scope", [False, True], ids=["no-scope", "account"])
|
||||
def test_restoring_file_access_outside_end_user_scope_does_not_read_storage(
|
||||
unbound_session_factory: sessionmaker[Session], account_scope: bool
|
||||
) -> None:
|
||||
scope = FileAccessScope(
|
||||
tenant_id="tenant", user_id="account", user_from=UserFrom.ACCOUNT, invoke_from=InvokeFrom.DEBUGGER
|
||||
)
|
||||
with bind_file_access_scope(scope) if account_scope else nullcontext():
|
||||
HumanInputSubmissionService(session_factory=unbound_session_factory).grant_submitted_file_access(
|
||||
app_id="app", workflow_run_id="run"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(params=[False, True], ids=["commit", "rollback-then-retry"])
|
||||
def fail_commit(request: pytest.FixtureRequest) -> bool:
|
||||
return request.param is True
|
||||
|
||||
Generated
-10
@@ -1562,11 +1562,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx": {
|
||||
"better-tailwindcss/enforce-logical-properties": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/base/prompt-editor/plugins/hitl-input-block/index.tsx": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 3
|
||||
@@ -1575,11 +1570,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx": {
|
||||
"better-tailwindcss/enforce-logical-properties": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/prompt-editor/plugins/hitl-input-block/pre-populate.tsx": {
|
||||
"better-tailwindcss/enforce-logical-properties": {
|
||||
"count": 2
|
||||
|
||||
@@ -1785,12 +1785,12 @@ export const results = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the latest IM sync run summary. The UI uses finished_at as the explicit sync time; the response does not include started_by.
|
||||
* Return the latest IM sync run summary. The UI uses finished_at as the explicit sync time; initiator_display_name is resolved from the initiating Account.
|
||||
*/
|
||||
export const get23 = oc
|
||||
.route({
|
||||
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.',
|
||||
'Return the latest IM sync run summary. The UI uses finished_at as the explicit sync time; initiator_display_name is resolved from the initiating Account.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getWorkspacesCurrentHumanInputV2ImSyncRunsLatest',
|
||||
@@ -1804,12 +1804,16 @@ export const latest = {
|
||||
results,
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or reuse a workspace run using the authenticated Account ID
|
||||
*/
|
||||
export const post22 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postWorkspacesCurrentHumanInputV2ImSyncRuns',
|
||||
path: '/workspaces/current/human-input/v2/im-sync-runs',
|
||||
summary: 'Start or reuse a workspace run using the authenticated Account ID',
|
||||
tags: ['console'],
|
||||
})
|
||||
.output(zPostWorkspacesCurrentHumanInputV2ImSyncRunsResponse)
|
||||
|
||||
@@ -2034,6 +2034,7 @@ export type HumanInputContact = {
|
||||
export type HumanInputContactSummary = {
|
||||
avatar_url?: string
|
||||
created_at: number
|
||||
email?: string | null
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
@@ -2060,6 +2061,7 @@ export type ImSyncRun = {
|
||||
error_message?: string | null
|
||||
finished_at?: number | null
|
||||
id: string
|
||||
initiator_display_name: string
|
||||
provider: ImProvider
|
||||
result_counts: ImSyncRunResultCounts
|
||||
started_at?: number | null
|
||||
|
||||
@@ -1752,6 +1752,7 @@ export const zUnchangedInput = z.object({
|
||||
export const zHumanInputContactSummary = z.object({
|
||||
avatar_url: z.string().optional().default(''),
|
||||
created_at: z.int(),
|
||||
email: z.string().nullish(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
@@ -3393,8 +3394,8 @@ export const zImSyncRunStatus = z.enum(['failed', 'queued', 'running', 'succeede
|
||||
*
|
||||
* 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.
|
||||
* The latest-only UI displays ``finished_at`` as the explicit sync time.
|
||||
* The initiator display name is resolved from the initiating Dify Account.
|
||||
*/
|
||||
export const zImSyncRun = z.object({
|
||||
channel_config_version: z.int().gte(1),
|
||||
@@ -3402,6 +3403,7 @@ export const zImSyncRun = z.object({
|
||||
error_message: z.string().nullish(),
|
||||
finished_at: z.int().nullish(),
|
||||
id: z.string(),
|
||||
initiator_display_name: z.string(),
|
||||
provider: zImProvider,
|
||||
result_counts: zImSyncRunResultCounts,
|
||||
started_at: z.int().nullish(),
|
||||
|
||||
@@ -97,9 +97,12 @@ type PromptEditorAriaProps = Pick<
|
||||
| 'aria-labelledby'
|
||||
| 'aria-describedby'
|
||||
| 'aria-invalid'
|
||||
| 'aria-multiline'
|
||||
| 'aria-disabled'
|
||||
>
|
||||
|
||||
type PromptEditorProps = PromptEditorAriaProps & {
|
||||
id?: string
|
||||
instanceId?: string
|
||||
children?: React.ReactNode
|
||||
compact?: boolean
|
||||
@@ -143,6 +146,9 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
'aria-invalid': ariaInvalid,
|
||||
'aria-multiline': ariaMultiline,
|
||||
'aria-disabled': ariaDisabled,
|
||||
id,
|
||||
instanceId,
|
||||
children,
|
||||
compact,
|
||||
@@ -244,12 +250,15 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
<LexicalComposer initialConfig={{ ...initialConfig, editable }}>
|
||||
<div className={cn('relative', wrapperClassName)} ref={onRef}>
|
||||
<PromptEditorContent
|
||||
id={id}
|
||||
aria-controls={ariaControls}
|
||||
aria-haspopup={ariaHasPopup}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-multiline={ariaMultiline}
|
||||
aria-disabled={ariaDisabled}
|
||||
compact={compact}
|
||||
className={className}
|
||||
placeholder={placeholder}
|
||||
|
||||
+59
-54
@@ -6,9 +6,8 @@ import type {
|
||||
} from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { ValueSelector } from '@/app/components/workflow/types'
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { BlockEnum, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import HITLInputComponentUI from '../component-ui'
|
||||
@@ -50,25 +49,26 @@ const renderComponent = (props: Partial<ComponentProps<typeof HITLInputComponent
|
||||
...props,
|
||||
}
|
||||
|
||||
const utils = render(
|
||||
<LexicalComposer
|
||||
initialConfig={{
|
||||
namespace: `hitl-input-test-${crypto.randomUUID()}`,
|
||||
onError: (error: Error) => {
|
||||
throw error
|
||||
},
|
||||
nodes: [HITLInputNode],
|
||||
}}
|
||||
>
|
||||
<HITLInputComponentUI {...defaultProps} />
|
||||
</LexicalComposer>,
|
||||
const initialConfig = {
|
||||
namespace: `hitl-input-test-${crypto.randomUUID()}`,
|
||||
onError: (error: Error) => {
|
||||
throw error
|
||||
},
|
||||
nodes: [HITLInputNode],
|
||||
}
|
||||
const subject = (readonly = defaultProps.readonly) => (
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<HITLInputComponentUI {...defaultProps} readonly={readonly} />
|
||||
</LexicalComposer>
|
||||
)
|
||||
const utils = render(subject())
|
||||
|
||||
return {
|
||||
...utils,
|
||||
onChange,
|
||||
onRename,
|
||||
onRemove,
|
||||
rerenderReadonly: (readonly: boolean) => utils.rerender(subject(readonly)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,56 +113,41 @@ describe('HITLInputComponentUI', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close the edit modal when readonly becomes true', async () => {
|
||||
it('should close the edit modal while readonly and allow reopening when editing resumes', async () => {
|
||||
const user = userEvent.setup()
|
||||
let setReadonlyValue: ((readonly: boolean) => void) | undefined
|
||||
const Harness = () => {
|
||||
const [readonly, setReadonly] = useState(false)
|
||||
const [namespace] = useState(() => `hitl-input-test-${crypto.randomUUID()}`)
|
||||
|
||||
useEffect(() => {
|
||||
setReadonlyValue = setReadonly
|
||||
return () => {
|
||||
setReadonlyValue = undefined
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<LexicalComposer
|
||||
initialConfig={{
|
||||
namespace,
|
||||
onError: (error: Error) => {
|
||||
throw error
|
||||
},
|
||||
nodes: [HITLInputNode],
|
||||
}}
|
||||
>
|
||||
<HITLInputComponentUI
|
||||
nodeId="node-1"
|
||||
varName="customer_name"
|
||||
workflowNodesMap={createWorkflowNodesMap()}
|
||||
onChange={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</LexicalComposer>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
const { rerenderReadonly } = renderComponent()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.edit' }))
|
||||
|
||||
expect(await screen.findByRole('textbox')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
setReadonlyValue?.(true)
|
||||
})
|
||||
rerenderReadonly(true)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.operation.edit' }),
|
||||
).not.toBeInTheDocument()
|
||||
rerenderReadonly(false)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.edit' }))
|
||||
|
||||
expect(await screen.findByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should enable edit and remove actions when an initially readonly field becomes editable', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onRemove, rerenderReadonly } = renderComponent({ readonly: true })
|
||||
|
||||
rerenderReadonly(false)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.edit' }))
|
||||
expect(await screen.findByRole('textbox')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.remove' }))
|
||||
expect(onRemove).toHaveBeenCalledExactlyOnceWith(varName)
|
||||
})
|
||||
|
||||
it('should render select option summary for constant options', () => {
|
||||
@@ -224,6 +209,26 @@ describe('HITLInputComponentUI', () => {
|
||||
expect(onRemove).toHaveBeenCalledWith(varName)
|
||||
expect(onRemove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should remove exactly once per click after repeated readonly transitions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onRemove, rerenderReadonly } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.remove' }))
|
||||
expect(onRemove).toHaveBeenCalledExactlyOnceWith(varName)
|
||||
|
||||
for (const expectedCalls of [2, 3]) {
|
||||
rerenderReadonly(true)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.operation.remove' }),
|
||||
).not.toBeInTheDocument()
|
||||
rerenderReadonly(false)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.remove' }))
|
||||
|
||||
expect(onRemove).toHaveBeenCalledTimes(expectedCalls)
|
||||
expect(onRemove).toHaveBeenLastCalledWith(varName)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit flow', () => {
|
||||
|
||||
+66
-18
@@ -164,6 +164,11 @@ describe('InputField', () => {
|
||||
await user.click(
|
||||
await screen.findByRole('option', { name: /appDebug\.variableConfig\.select\b/ }),
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'appDebug.variableConfig.addOption' }))
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: 'appDebug.variableConfig.options 1' }),
|
||||
'alpha',
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledExactlyOnceWith(
|
||||
@@ -467,7 +472,7 @@ describe('InputField', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should switch to select payload when field type changes', async () => {
|
||||
it('should block insertion of a select field without options', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
@@ -495,21 +500,55 @@ describe('InputField', () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(onChange.mock.calls[0]![0]).toEqual({
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'valid_name',
|
||||
option_source: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: [],
|
||||
},
|
||||
})
|
||||
const error = 'appDebug.variableConfig.errorMsg.atLeastOneOption'
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(error)
|
||||
expect(
|
||||
screen.queryByText(
|
||||
/workflowHumanInput\.nodes\.humanInput\.insertInputField\.prePopulateField/i,
|
||||
),
|
||||
).not.toBeInTheDocument()
|
||||
screen.getByRole('button', { name: 'appDebug.variableConfig.addOption' }),
|
||||
).toHaveAccessibleDescription(error)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reject blank and duplicate select options until they are corrected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<InputField
|
||||
nodeId="human"
|
||||
isEdit
|
||||
payload={{
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'choice',
|
||||
option_source: { type: 'constant', selector: [], value: ['alpha', ' '] },
|
||||
}}
|
||||
onChange={onChange}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const option = screen.getByRole('textbox', { name: 'appDebug.variableConfig.options 2' })
|
||||
const save = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
await user.click(save)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('common.errorMsg.fieldRequired')
|
||||
expect(option).toHaveAccessibleDescription(/common\.errorMsg\.fieldRequired/)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
|
||||
await user.clear(option)
|
||||
await user.type(option, ' alpha ')
|
||||
await user.keyboard('{Control>}{Enter}{/Control}')
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'appDebug.variableConfig.errorMsg.optionRepeat',
|
||||
)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
|
||||
await user.clear(option)
|
||||
await user.type(option, ' Alpha ')
|
||||
await user.click(save)
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(onChange).toHaveBeenCalledExactlyOnceWith({
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'choice',
|
||||
option_source: { type: 'constant', selector: [], value: ['alpha', ' Alpha '] },
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep paragraph pre-populate editor available after switching back to paragraph', async () => {
|
||||
@@ -677,15 +716,16 @@ describe('InputField', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should only allow array[string] variables for select option source', async () => {
|
||||
it('should save a select using an array[string] variable without constant options', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<InputField
|
||||
nodeId="human"
|
||||
isEdit={false}
|
||||
payload={createPayload()}
|
||||
onChange={vi.fn()}
|
||||
onChange={onChange}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
@@ -703,9 +743,17 @@ describe('InputField', () => {
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /common.setVarValuePlaceholder/ }))
|
||||
expect(await screen.findByText('choices')).toBeInTheDocument()
|
||||
const choices = await screen.findByText('choices')
|
||||
expect(screen.queryByText('text', { exact: true })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('files', { exact: true })).not.toBeInTheDocument()
|
||||
await user.click(choices)
|
||||
await user.click(screen.getByRole('button', { name: /insertInputField\.insert/ }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledExactlyOnceWith({
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'valid_name',
|
||||
option_source: { type: 'variable', selector: ['code', 'choices'], value: [] },
|
||||
})
|
||||
})
|
||||
|
||||
it('should save single file upload settings', async () => {
|
||||
|
||||
@@ -72,7 +72,7 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
|
||||
return () => {
|
||||
if (editBtn) editBtn.removeEventListener('click', showEditModal)
|
||||
}
|
||||
}, [showEditModal])
|
||||
}, [showEditModal, readonly])
|
||||
|
||||
const removeBtnRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
@@ -83,7 +83,7 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
|
||||
return () => {
|
||||
if (removeBtn) removeBtn.removeEventListener('click', removeHandler)
|
||||
}
|
||||
}, [onRemove, varName])
|
||||
}, [onRemove, varName, readonly])
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newPayload: FormInputItem) => {
|
||||
@@ -131,8 +131,8 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
|
||||
}, [inputTypeLabel, paragraphDefault?.value, resolvedFormInput, t])
|
||||
|
||||
return (
|
||||
<div className="group relative flex h-8 w-full items-center rounded-lg border-[1.5px] border-components-input-border-active bg-background-default-hover pr-0.5 pl-1.5 select-none">
|
||||
<div className="absolute -top-3 left-2.5">
|
||||
<div className="group relative flex h-8 w-full items-center rounded-lg border-[1.5px] border-components-input-border-active bg-background-default-hover ps-1.5 pe-0.5 select-none">
|
||||
<div className="absolute inset-s-2.5 -top-3">
|
||||
<div className="absolute bottom-1 h-[1.5px] w-full bg-background-default-subtle"></div>
|
||||
<div className="relative flex items-center space-x-0.5 px-1 text-text-accent-light-mode-only">
|
||||
<span aria-hidden className="i-custom-vender-workflow-variable-x size-3" />
|
||||
@@ -140,7 +140,7 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center gap-x-2 pr-5">
|
||||
<div className="flex w-full items-center gap-x-2 pe-5">
|
||||
<div className="min-w-0 grow">
|
||||
{variableSelector ? (
|
||||
<VariableBlock
|
||||
|
||||
@@ -51,8 +51,9 @@ const InputField: React.FC<InputFieldProps> = ({
|
||||
onChange,
|
||||
onCancel,
|
||||
}) => {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const rootRef = useRef<HTMLFormElement>(null)
|
||||
const outputVariableNameInputId = React.useId()
|
||||
const optionsErrorId = React.useId()
|
||||
const { t } = useTranslation(['appDebug', 'common', 'workflow', 'workflowHumanInput'])
|
||||
const [tempPayload, setTempPayload] = useState<FormInputItem>(
|
||||
() => payload || createDefaultParagraphFormInput(),
|
||||
@@ -100,8 +101,26 @@ const InputField: React.FC<InputFieldProps> = ({
|
||||
const nameValid = useMemo(() => {
|
||||
return !!tempPayload.output_variable_name.trim() && !variableNameError
|
||||
}, [tempPayload.output_variable_name, variableNameError])
|
||||
|
||||
let optionsError = ''
|
||||
if (isSelectFormInput(tempPayload) && tempPayload.option_source.type === 'constant') {
|
||||
const options = tempPayload.option_source.value.map((option) => option.trim())
|
||||
const blankOptionIndex = options.findIndex((option) => !option)
|
||||
if (options.length === 0) {
|
||||
optionsError = t(($) => $['variableConfig.errorMsg.atLeastOneOption'], { ns: 'appDebug' })
|
||||
} else if (blankOptionIndex !== -1) {
|
||||
const label = t(($) => $['variableConfig.options'], { ns: 'appDebug' })
|
||||
optionsError = t(($) => $['errorMsg.fieldRequired'], {
|
||||
ns: 'common',
|
||||
field: `${label} ${blankOptionIndex + 1}`,
|
||||
})
|
||||
} else if (new Set(options).size !== options.length) {
|
||||
optionsError = t(($) => $['variableConfig.errorMsg.optionRepeat'], { ns: 'appDebug' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!nameValid) return
|
||||
if (!nameValid || optionsError) return
|
||||
if (isFileListFormInput(tempPayload)) {
|
||||
const value = tempPayload.number_limits ?? 5
|
||||
if (!Number.isFinite(value)) {
|
||||
@@ -113,7 +132,7 @@ const InputField: React.FC<InputFieldProps> = ({
|
||||
}
|
||||
}
|
||||
onChange(tempPayload)
|
||||
}, [nameValid, onChange, tempPayload])
|
||||
}, [nameValid, onChange, optionsError, tempPayload])
|
||||
const handleTypeChange = useCallback((item: TypeSelectItem) => {
|
||||
setTempPayload((prev) =>
|
||||
createDefaultFormInputByType(item.value as FormInputItem['type'], prev.output_variable_name),
|
||||
@@ -219,7 +238,7 @@ const InputField: React.FC<InputFieldProps> = ({
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (event.repeat) return
|
||||
handleSave()
|
||||
rootRef.current?.requestSubmit()
|
||||
},
|
||||
{
|
||||
enabled: nameValid,
|
||||
@@ -230,9 +249,27 @@ const InputField: React.FC<InputFieldProps> = ({
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
// oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- The form handles bubbling Enter after child inputs handle their own keys.
|
||||
<form
|
||||
ref={rootRef}
|
||||
className="flex max-h-(--shortcut-popup-max-height,80dvh) w-93 flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"
|
||||
className="flex max-h-(--shortcut-popup-max-height,80dvh) w-93 max-w-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
handleSave()
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
// Preserve Mod+Enter as the save shortcut while editing a field.
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.nativeEvent.isComposing &&
|
||||
event.target instanceof HTMLInputElement &&
|
||||
event.target.form === event.currentTarget
|
||||
)
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<div className="shrink-0 p-3 pb-2">
|
||||
<div className="system-md-semibold text-text-primary">
|
||||
@@ -310,7 +347,7 @@ const InputField: React.FC<InputFieldProps> = ({
|
||||
filterVar={(varPayload) => varPayload.type === VarType.arrayString}
|
||||
/>
|
||||
<TypeSwitch
|
||||
className="absolute bottom-1 left-1.5"
|
||||
className="absolute inset-s-1.5 bottom-1"
|
||||
isVariable
|
||||
onIsVariableChange={handleSelectOptionSourceTypeChange}
|
||||
/>
|
||||
@@ -324,7 +361,18 @@ const InputField: React.FC<InputFieldProps> = ({
|
||||
<ConfigSelect
|
||||
options={tempPayload.option_source.value}
|
||||
onChange={handleSelectOptionsChange}
|
||||
errorMessage={optionsError}
|
||||
errorId={optionsErrorId}
|
||||
/>
|
||||
{optionsError && (
|
||||
<div
|
||||
id={optionsErrorId}
|
||||
role="alert"
|
||||
className="mt-1 px-1 system-xs-regular text-text-destructive-secondary"
|
||||
>
|
||||
{optionsError}
|
||||
</div>
|
||||
)}
|
||||
<TypeSwitch
|
||||
className="mt-2"
|
||||
isVariable={false}
|
||||
@@ -361,13 +409,15 @@ const InputField: React.FC<InputFieldProps> = ({
|
||||
</div>
|
||||
<div className="shrink-0 bg-components-panel-bg p-3">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button onClick={onCancel}>{t(($) => $['operation.cancel'], { ns: 'common' })}</Button>
|
||||
<Button type="button" onClick={onCancel}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</Button>
|
||||
{isEdit ? (
|
||||
<Button variant="primary" onClick={handleSave} disabled={!nameValid}>
|
||||
<Button type="submit" variant="primary" disabled={!nameValid}>
|
||||
{t(($) => $['operation.save'], { ns: 'common' })}
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="flex" variant="primary" disabled={!nameValid} onClick={handleSave}>
|
||||
<Button type="submit" className="flex" variant="primary" disabled={!nameValid}>
|
||||
<span>{t(($) => $[`${i18nPrefix}.insert`], { ns: 'workflowHumanInput' })}</span>
|
||||
<KbdGroup>
|
||||
{formatForDisplay(SAVE_HOTKEY, { parts: true }).map((key) => (
|
||||
@@ -380,7 +430,7 @@ const InputField: React.FC<InputFieldProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -65,9 +65,12 @@ type PromptEditorContentAriaProps = Pick<
|
||||
| 'aria-labelledby'
|
||||
| 'aria-describedby'
|
||||
| 'aria-invalid'
|
||||
| 'aria-multiline'
|
||||
| 'aria-disabled'
|
||||
>
|
||||
|
||||
type PromptEditorContentProps = PromptEditorContentAriaProps & {
|
||||
id?: string
|
||||
compact?: boolean
|
||||
className?: string
|
||||
placeholder?: string | React.ReactNode
|
||||
@@ -114,6 +117,9 @@ const PromptEditorContent: FC<PromptEditorContentProps> = ({
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
'aria-invalid': ariaInvalid,
|
||||
'aria-multiline': ariaMultiline,
|
||||
'aria-disabled': ariaDisabled,
|
||||
id,
|
||||
compact,
|
||||
className,
|
||||
placeholder,
|
||||
@@ -147,12 +153,15 @@ const PromptEditorContent: FC<PromptEditorContentProps> = ({
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<ContentEditable
|
||||
id={id}
|
||||
aria-controls={ariaControls}
|
||||
aria-haspopup={ariaHasPopup}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-multiline={ariaMultiline}
|
||||
aria-disabled={ariaDisabled}
|
||||
className={cn(
|
||||
'group/editable text-text-secondary outline-hidden group-[.clamp]:max-h-24 group-[.clamp]:overflow-y-auto',
|
||||
compact ? 'text-[13px] leading-5' : 'text-sm/6',
|
||||
|
||||
@@ -140,13 +140,12 @@ it('replaces the visible panel configuration on migration and undo without closi
|
||||
.element(panel.getByRole('textbox', { name: 'workflow.nodes.humanInput.timeout.title' }))
|
||||
.toHaveValue('36')
|
||||
await panel.getByRole('button', { name: 'workflow.common.configure' }).click()
|
||||
await expect
|
||||
.element(page.getByRole('textbox', { name: 'workflow.nodes.humanInputV2.template.subject' }))
|
||||
.toHaveValue('Converted approval')
|
||||
const subject = page.getByRole('textbox', {
|
||||
name: 'workflow.nodes.humanInputV2.template.subject',
|
||||
})
|
||||
await expect.poll(() => subject.element().textContent).toBe('Converted approval')
|
||||
await page.getByRole('button', { name: 'common.operation.cancel' }).click()
|
||||
await expect
|
||||
.element(page.getByRole('textbox', { name: 'workflow.nodes.humanInputV2.template.subject' }))
|
||||
.not.toBeInTheDocument()
|
||||
await expect.element(subject).not.toBeInTheDocument()
|
||||
await lastRun.click()
|
||||
await expect.element(lastRun).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
|
||||
import { detectPlatform } from '@tanstack/react-hotkeys'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { ReactFlowProvider } from 'reactflow'
|
||||
import { page, userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { createNode } from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import { seedModelProviderQueries } from '@/test/console/model-provider'
|
||||
import { createTestQueryClient } from '@/test/query-client'
|
||||
import MessageTemplateSubject from '../components/message-template-subject'
|
||||
|
||||
const insertVariableLabel = 'workflow.nodes.humanInputV2.template.insertVariable'
|
||||
const searchVariableLabel = 'workflow.common.searchVar'
|
||||
|
||||
function MessageTemplateSubjectHarness() {
|
||||
const [subject, setSubject] = useState('Before placeholder After')
|
||||
const [queryClient] = useState(() => {
|
||||
const client = createTestQueryClient()
|
||||
seedModelProviderQueries(client)
|
||||
return client
|
||||
})
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ReactFlowProvider>
|
||||
<Dialog defaultOpen>
|
||||
<DialogContent className="w-180">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Message template</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Field name="subject">
|
||||
<FieldLabel id="template-subject-label" htmlFor="template-subject">
|
||||
Subject
|
||||
</FieldLabel>
|
||||
<MessageTemplateSubject
|
||||
id="template-subject"
|
||||
aria-labelledby="template-subject-label"
|
||||
availableNodes={[
|
||||
createNode({
|
||||
id: 'loop',
|
||||
data: { type: BlockEnum.Loop, title: 'Contract loop' },
|
||||
}),
|
||||
]}
|
||||
value={subject}
|
||||
onValueChange={setSubject}
|
||||
availableVars={[
|
||||
{
|
||||
nodeId: 'loop',
|
||||
title: 'Contract loop',
|
||||
vars: [{ variable: 'round', type: VarType.number }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<output aria-label="Draft subject">{subject}</output>
|
||||
<textarea
|
||||
aria-label="Clipboard source"
|
||||
value={'Pasted {{#loop.round#}}\nNext'}
|
||||
readOnly
|
||||
/>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ReactFlowProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
it('replaces a native subject selection through the portalled picker and keeps Escape local to the picker', async () => {
|
||||
// Chromium must exercise native selection and focus moving through Dialog and Popover portals.
|
||||
// A happy-dom-only selection simulation does not prove typing resumes after the inserted variable.
|
||||
await render(<MessageTemplateSubjectHarness />)
|
||||
const dialog = page.getByRole('dialog', { name: 'Message template', exact: true })
|
||||
const subject = page.getByRole('textbox', { name: 'Subject', exact: true })
|
||||
await expect.element(dialog).toBeVisible()
|
||||
await subject.click()
|
||||
await userEvent.keyboard(detectPlatform() === 'mac' ? '{Meta>}{ArrowLeft}{/Meta}' : '{Home}')
|
||||
await userEvent.keyboard('{ArrowRight>7/}{Shift>}{ArrowRight>11/}{/Shift}')
|
||||
expect(window.getSelection()?.toString()).toBe('placeholder')
|
||||
|
||||
await page.getByRole('button', { name: insertVariableLabel }).click()
|
||||
const search = page.getByRole('searchbox', { name: searchVariableLabel })
|
||||
await expect.element(search).toBeVisible()
|
||||
await search.fill('round')
|
||||
await page.getByText('round', { exact: true }).click()
|
||||
|
||||
await expect.element(subject.getByText('round', { exact: true })).toBeVisible()
|
||||
await expect.element(subject).not.toHaveTextContent('{{#loop.round#}}')
|
||||
await expect.element(subject).toHaveFocus()
|
||||
await userEvent.keyboard('!')
|
||||
await expect
|
||||
.element(page.getByRole('status', { name: 'Draft subject' }))
|
||||
.toHaveTextContent('Before {{#loop.round#}}! After')
|
||||
|
||||
await page.getByRole('button', { name: insertVariableLabel }).click()
|
||||
await expect.element(search).toBeVisible()
|
||||
await userEvent.keyboard('{Escape}')
|
||||
await expect.element(search).not.toBeInTheDocument()
|
||||
await expect.element(dialog).toBeVisible()
|
||||
await expect.element(subject.getByText('round', { exact: true })).toBeVisible()
|
||||
|
||||
await subject.click()
|
||||
await userEvent.keyboard('{Enter}{Shift>}{Enter}{/Shift}')
|
||||
const draft = page.getByRole('status', { name: 'Draft subject' })
|
||||
await expect.poll(() => draft.element().textContent).toBe('Before {{#loop.round#}}! After')
|
||||
|
||||
const modifier = detectPlatform() === 'mac' ? 'Meta' : 'Control'
|
||||
await page.getByRole('textbox', { name: 'Clipboard source' }).click()
|
||||
await userEvent.keyboard(`{${modifier}>}a{/${modifier}}`)
|
||||
await userEvent.copy()
|
||||
await subject.click()
|
||||
await userEvent.keyboard(`{${modifier}>}a{/${modifier}}`)
|
||||
await userEvent.paste()
|
||||
await expect.poll(() => draft.element().textContent).toBe('Pasted {{#loop.round#}} Next')
|
||||
await expect.element(subject.getByText('round', { exact: true })).toBeVisible()
|
||||
})
|
||||
+151
-45
@@ -11,15 +11,16 @@ import type { ComponentProps } from 'react'
|
||||
import type { EditorResource } from '@/app/components/workflow/editor-resource/resource'
|
||||
import type { Shape } from '@/app/components/workflow/hooks-store/store'
|
||||
import { ORPCError } from '@orpc/client'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import { act, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { ReactFlowProvider } from 'reactflow'
|
||||
import { replacePromptEditorText } from '@/app/components/base/prompt-editor/plugins/__tests__/test-helpers'
|
||||
import { createNode } from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { EditorResourceScope } from '@/app/components/workflow/editor-resource/provider'
|
||||
import { HooksStoreContextProvider } from '@/app/components/workflow/hooks-store/provider'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
import { seedModelProviderQueries } from '@/test/console/model-provider'
|
||||
import { createDeferred } from '@/test/deferred'
|
||||
@@ -43,6 +44,19 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
const originalBody = 'Original body {{#url#}}'
|
||||
const subjectVariableButtonLabel = 'workflow.nodes.humanInputV2.template.insertVariable'
|
||||
const loopTemplateVariables = {
|
||||
availableVars: [
|
||||
{
|
||||
nodeId: 'loop',
|
||||
title: 'Contract loop',
|
||||
vars: [{ variable: 'round', type: VarType.number }],
|
||||
},
|
||||
],
|
||||
availableNodes: [
|
||||
createNode({ id: 'loop', data: { type: BlockEnum.Loop, title: 'Contract loop' } }),
|
||||
],
|
||||
}
|
||||
|
||||
vi.mock('@/service/console', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/console')>()
|
||||
@@ -164,6 +178,21 @@ const getBodyEditor = () =>
|
||||
|
||||
const setBody = async (value: string) => replacePromptEditorText(getBodyEditor(), value)
|
||||
|
||||
const getSubjectEditor = () =>
|
||||
screen.getByRole('textbox', { name: 'workflow.nodes.humanInputV2.template.subject' })
|
||||
|
||||
const setSubject = async (value: string) => replacePromptEditorText(getSubjectEditor(), value)
|
||||
|
||||
const openSubjectVariables = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByRole('button', { name: subjectVariableButtonLabel }))
|
||||
return screen.findByRole('searchbox', { name: 'workflow.common.searchVar' })
|
||||
}
|
||||
|
||||
const insertSubjectRound = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.type(await openSubjectVariables(user), 'round')
|
||||
await user.click(screen.getByText('round', { exact: true }))
|
||||
}
|
||||
|
||||
const expectNoTemplateTest = () => {
|
||||
expect(mocks.workflowTest).not.toHaveBeenCalled()
|
||||
expect(mocks.chatflowTest).not.toHaveBeenCalled()
|
||||
@@ -186,11 +215,16 @@ describe('Human Input v2 Message Template', () => {
|
||||
})
|
||||
await openTemplate(user)
|
||||
|
||||
const subject = screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')
|
||||
expect(subject).toHaveValue('Original subject{{#start.email#}}')
|
||||
const subject = getSubjectEditor()
|
||||
await user.click(
|
||||
screen.getByText('workflow.nodes.humanInputV2.template.subject', { exact: true }),
|
||||
)
|
||||
expect(subject).toHaveFocus()
|
||||
expect(subject).toHaveTextContent('Original subject')
|
||||
expect(within(subject).getByText('email', { exact: true })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Committed body')).toHaveTextContent(originalBody)
|
||||
|
||||
await user.type(subject, ' updated')
|
||||
await setSubject('Original subject{{#start.email#}} updated')
|
||||
await setBody('Updated body {{#url#}}')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
@@ -203,45 +237,126 @@ describe('Human Input v2 Message Template', () => {
|
||||
)
|
||||
expect(screen.getByLabelText('Committed body')).toHaveTextContent('Updated body {{#url#}}')
|
||||
await openTemplate(user)
|
||||
expect(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')).toHaveValue(
|
||||
'Original subject{{#start.email#}} updated',
|
||||
expect(getSubjectEditor()).toHaveTextContent('Original subject')
|
||||
expect(within(getSubjectEditor()).getByText('email', { exact: true })).toBeInTheDocument()
|
||||
expect(getSubjectEditor()).toHaveTextContent('updated')
|
||||
})
|
||||
|
||||
it('inserts a parent loop variable as a visible badge and saves its complete selector', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { props } = renderTemplate(loopTemplateVariables)
|
||||
await openTemplate(user)
|
||||
await setSubject('Review ')
|
||||
|
||||
await insertSubjectRound(user)
|
||||
|
||||
const subject = getSubjectEditor()
|
||||
expect(subject).toHaveTextContent('Review')
|
||||
expect(within(subject).getByText('Contract loop', { exact: true })).toBeInTheDocument()
|
||||
expect(within(subject).getByText('round', { exact: true })).toBeInTheDocument()
|
||||
expect(subject).not.toHaveTextContent('{{#loop.round#}}')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
expect(props.onChange).toHaveBeenCalledExactlyOnceWith({
|
||||
subject: 'Review {{#loop.round#}}',
|
||||
body: originalBody,
|
||||
})
|
||||
expectNoTemplateTest()
|
||||
})
|
||||
|
||||
it('closes the subject variable picker on Escape and discards inserted variables on Cancel', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { props } = renderTemplate(loopTemplateVariables)
|
||||
await openTemplate(user)
|
||||
const subject = getSubjectEditor()
|
||||
await user.type(await openSubjectVariables(user), 'round')
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole('searchbox', { name: 'workflow.common.searchVar' }),
|
||||
).not.toBeInTheDocument(),
|
||||
)
|
||||
expect(subject).toHaveTextContent('Original subject')
|
||||
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
|
||||
|
||||
await setSubject('Original subject')
|
||||
await insertSubjectRound(user)
|
||||
expect(within(subject).getByText('round', { exact: true })).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
await openTemplate(user)
|
||||
expect(getSubjectEditor()).toHaveTextContent('Original subject')
|
||||
expect(within(getSubjectEditor()).queryByText('round', { exact: true })).not.toBeInTheDocument()
|
||||
expect(props.onChange).not.toHaveBeenCalled()
|
||||
expectNoTemplateTest()
|
||||
})
|
||||
|
||||
it('collects a variable inserted in the subject before saving and sending a template test', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { props } = renderTemplate(loopTemplateVariables)
|
||||
await openTest(user)
|
||||
await setSubject('Original subject')
|
||||
await insertSubjectRound(user)
|
||||
await sendTest(user)
|
||||
expect(
|
||||
await screen.findByText('workflow.nodes.humanInputV2.template.testInputsRequired'),
|
||||
).toBeInTheDocument()
|
||||
expect(mocks.syncDraft).not.toHaveBeenCalled()
|
||||
expectNoTemplateTest()
|
||||
|
||||
await user.type(screen.getByLabelText('loop.round'), '3')
|
||||
await sendTest(user)
|
||||
expect(
|
||||
await screen.findByText('workflow.nodes.humanInputV2.template.testCompleted'),
|
||||
).toBeInTheDocument()
|
||||
expect(props.onChange).toHaveBeenCalledWith({
|
||||
subject: 'Original subject{{#loop.round#}}',
|
||||
body: originalBody,
|
||||
})
|
||||
expect(mocks.workflowTest).toHaveBeenCalledExactlyOnceWith({
|
||||
params: { app_id: 'app-template-test', node_id: 'human-input-v2' },
|
||||
body: { channel: 'email', inputs: { '#loop.round#': 3 } },
|
||||
})
|
||||
expect(mocks.chatflowTest).not.toHaveBeenCalled()
|
||||
|
||||
await user.click(
|
||||
screen.getByText('workflow.nodes.humanInputV2.template.subject', { exact: true }),
|
||||
)
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
expect(getSubjectEditor()).toHaveFocus()
|
||||
expect(
|
||||
screen.getByText('workflow.nodes.humanInputV2.template.testCompleted'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
await setSubject('Updated subject{{#loop.round#}}')
|
||||
expect(
|
||||
screen.queryByText('workflow.nodes.humanInputV2.template.testCompleted'),
|
||||
).not.toBeInTheDocument()
|
||||
expect(mocks.workflowTest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('discards local drafts on Cancel and Escape and restores trigger focus', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTemplate()
|
||||
await openTemplate(user)
|
||||
const subject = screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')
|
||||
await user.clear(subject)
|
||||
await user.type(subject, 'Unsaved')
|
||||
await setSubject('Unsaved')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'workflow.common.configure' })).toHaveFocus(),
|
||||
)
|
||||
await openTemplate(user)
|
||||
expect(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')).toHaveValue(
|
||||
'Original subject',
|
||||
)
|
||||
expect(getSubjectEditor()).toHaveTextContent('Original subject')
|
||||
|
||||
await user.clear(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject'))
|
||||
await user.type(
|
||||
screen.getByLabelText('workflow.nodes.humanInputV2.template.subject'),
|
||||
'Discard with Escape',
|
||||
)
|
||||
await setSubject('Discard with Escape')
|
||||
await user.keyboard('{Escape}')
|
||||
await openTemplate(user)
|
||||
expect(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')).toHaveValue(
|
||||
'Original subject',
|
||||
)
|
||||
expect(getSubjectEditor()).toHaveTextContent('Original subject')
|
||||
})
|
||||
|
||||
it('validates both local fields and does not submit twice', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { props } = renderTemplate()
|
||||
await openTemplate(user)
|
||||
await user.clear(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject'))
|
||||
await setSubject('')
|
||||
await setBody('')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
@@ -255,10 +370,7 @@ describe('Human Input v2 Message Template', () => {
|
||||
expect(screen.getByText('workflow.nodes.humanInputV2.error.bodyRequired')).toBeInTheDocument()
|
||||
expect(props.onChange).not.toHaveBeenCalled()
|
||||
|
||||
await user.type(
|
||||
screen.getByLabelText('workflow.nodes.humanInputV2.template.subject'),
|
||||
'Subject',
|
||||
)
|
||||
await setSubject('Subject')
|
||||
await setBody('Body {{#url#}}')
|
||||
const save = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
await user.dblClick(save)
|
||||
@@ -273,7 +385,9 @@ describe('Human Input v2 Message Template', () => {
|
||||
})
|
||||
await openTemplate(user)
|
||||
|
||||
expect(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')).toBeDisabled()
|
||||
expect(getSubjectEditor()).toHaveAttribute('contenteditable', 'false')
|
||||
expect(getSubjectEditor()).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(screen.getByRole('button', { name: subjectVariableButtonLabel })).toBeDisabled()
|
||||
expect(getBodyEditor()).toHaveAttribute('contenteditable', 'false')
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.save' })).not.toBeInTheDocument()
|
||||
expect(props.onChange).not.toHaveBeenCalled()
|
||||
@@ -355,18 +469,12 @@ describe('Human Input v2 Message Template', () => {
|
||||
).toBeInTheDocument()
|
||||
expectNoTemplateTest()
|
||||
expect(mocks.syncDraft).not.toHaveBeenCalled()
|
||||
await user.clear(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject'))
|
||||
await user.type(
|
||||
screen.getByLabelText('workflow.nodes.humanInputV2.template.subject'),
|
||||
'Unsaved test subject',
|
||||
)
|
||||
await setSubject('Unsaved test subject')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||
expect(props.onChange).not.toHaveBeenCalled()
|
||||
await waitFor(() => expect(testTrigger).toHaveFocus())
|
||||
await openTemplate(user)
|
||||
expect(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')).toHaveValue(
|
||||
'Original subject',
|
||||
)
|
||||
expect(getSubjectEditor()).toHaveTextContent('Original subject')
|
||||
})
|
||||
|
||||
it('offers only the workspace configured Email and Slack channels for template tests', async () => {
|
||||
@@ -522,11 +630,7 @@ describe('Human Input v2 Message Template', () => {
|
||||
mode: chat ? 'advanced-chat' : 'workflow',
|
||||
})
|
||||
await openTest(user)
|
||||
await user.clear(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject'))
|
||||
await user.type(
|
||||
screen.getByLabelText('workflow.nodes.humanInputV2.template.subject'),
|
||||
'Updated test subject',
|
||||
)
|
||||
await setSubject('Updated test subject')
|
||||
await user.click(
|
||||
screen.getByRole('combobox', { name: 'workflow.nodes.humanInputV2.template.testChannel' }),
|
||||
)
|
||||
@@ -575,7 +679,8 @@ describe('Human Input v2 Message Template', () => {
|
||||
await openTest(user)
|
||||
await sendTest(user)
|
||||
expect(mocks.syncDraft).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')).toBeDisabled()
|
||||
expect(getSubjectEditor()).toHaveAttribute('contenteditable', 'false')
|
||||
expect(getSubjectEditor()).toHaveAttribute('aria-disabled', 'true')
|
||||
|
||||
if (change === 'app changes') {
|
||||
rerenderEnvironment({
|
||||
@@ -632,9 +737,7 @@ describe('Human Input v2 Message Template', () => {
|
||||
expect(
|
||||
screen.queryByText('workflow.nodes.humanInputV2.template.testCompleted'),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')).toHaveValue(
|
||||
'Original subject',
|
||||
)
|
||||
expect(getSubjectEditor()).toHaveTextContent('Original subject')
|
||||
},
|
||||
)
|
||||
|
||||
@@ -777,7 +880,10 @@ describe('Human Input v2 Message Template', () => {
|
||||
await waitFor(() => expect(mocks.workflowTest).toHaveBeenCalledOnce())
|
||||
expect(mocks.syncDraft).toHaveBeenCalledTimes(1)
|
||||
expect(props.onChange).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')).toBeDisabled()
|
||||
expect(getSubjectEditor()).toHaveAttribute('contenteditable', 'false')
|
||||
expect(getSubjectEditor()).toHaveAttribute('aria-disabled', 'true')
|
||||
|
||||
expect(screen.getByRole('button', { name: subjectVariableButtonLabel })).toBeDisabled()
|
||||
|
||||
await act(async () => request.resolve({}))
|
||||
expect(
|
||||
|
||||
@@ -3,8 +3,11 @@ import type {
|
||||
ListContactsResponse,
|
||||
} from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { HumanInputV2NodeType } from '../types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { replacePromptEditorText } from '@/app/components/base/prompt-editor/plugins/__tests__/test-helpers'
|
||||
import { createNode, createTriggerNode } from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
import { seedAccountProfileQuery } from '@/test/console/account-profile'
|
||||
@@ -52,7 +55,7 @@ const createData = (overrides: Partial<HumanInputV2NodeType> = {}): HumanInputV2
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderPanel = (data = createData()) => {
|
||||
const renderPanel = (data = createData(), entry?: Node) => {
|
||||
const queryClient = createChannelsQueryClient(['resend', 'slack'])
|
||||
seedToolProviders(queryClient, {
|
||||
builtInTools: [],
|
||||
@@ -62,8 +65,11 @@ const renderPanel = (data = createData()) => {
|
||||
})
|
||||
queryClient.setQueryData(consoleQuery.spec.schemaDefinitions.get.queryKey(), [])
|
||||
seedAccountProfileQuery(queryClient, { email: 'reviewer@example.com' })
|
||||
const nodes = entry ? [entry, createNode({ id: 'node', data })] : undefined
|
||||
return renderNodePanel(HumanInputV2Panel, data, {
|
||||
queryClient,
|
||||
nodes,
|
||||
initialStoreState: nodes ? { nodes } : undefined,
|
||||
hooksStoreProps: { doSyncWorkflowDraft: async () => null },
|
||||
})
|
||||
}
|
||||
@@ -89,8 +95,7 @@ describe('Human Input v2 panel composition', () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.common.configure' }))
|
||||
const subject = screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')
|
||||
await user.clear(subject)
|
||||
await user.type(subject, 'Updated subject')
|
||||
await replacePromptEditorText(subject, 'Updated subject')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
expect(data().message_template).toEqual({
|
||||
subject: 'Updated subject',
|
||||
@@ -111,6 +116,19 @@ describe('Human Input v2 panel composition', () => {
|
||||
expect(data().debug_mode).toEqual({ enabled: true, channels: ['email'] })
|
||||
})
|
||||
|
||||
it('disables Initiator when the workflow starts from a Schedule trigger', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel(createData(), createTriggerNode(BlockEnum.TriggerSchedule))
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'workflow.nodes.humanInputV2.recipients.addContact' }),
|
||||
)
|
||||
const initiator = await screen.findByRole('option', {
|
||||
name: 'workflow.nodes.humanInputV2.recipients.type.initiator',
|
||||
})
|
||||
expect(initiator).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
|
||||
it('keeps stored v2 settings intact and unavailable while the feature is disabled', () => {
|
||||
hitlIm.enabled = false
|
||||
const stored = createData({
|
||||
|
||||
+1
@@ -35,6 +35,7 @@ function Harness({ observe }: { observe: (value: HumanInputV2Recipient[]) => voi
|
||||
nodeId="hitl"
|
||||
value={value}
|
||||
readonly={false}
|
||||
isTriggerMode={false}
|
||||
onChange={(next) => {
|
||||
setValue(next)
|
||||
observe(next)
|
||||
|
||||
@@ -38,10 +38,12 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference
|
||||
const Harness = ({
|
||||
initial = [],
|
||||
readonly = false,
|
||||
isTriggerMode = false,
|
||||
observe = () => {},
|
||||
}: {
|
||||
initial?: HumanInputV2Recipient[]
|
||||
readonly?: boolean
|
||||
isTriggerMode?: boolean
|
||||
observe?: (value: HumanInputV2Recipient[]) => void
|
||||
}) => {
|
||||
const [value, setValue] = useState(initial)
|
||||
@@ -49,6 +51,7 @@ const Harness = ({
|
||||
<Recipients
|
||||
nodeId="hitl"
|
||||
readonly={readonly}
|
||||
isTriggerMode={isTriggerMode}
|
||||
value={value}
|
||||
onChange={(next) => {
|
||||
setValue(next)
|
||||
@@ -66,6 +69,9 @@ const openPicker = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
return screen.findByRole('listbox')
|
||||
}
|
||||
const field = () => screen.getByRole('combobox')
|
||||
const initiatorLabel = 'workflow.nodes.humanInputV2.recipients.type.initiator'
|
||||
const initiatorUnavailableMessage =
|
||||
'workflow.nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow'
|
||||
beforeEach(resetContactApi)
|
||||
|
||||
describe('recipient selection through generated queries', () => {
|
||||
@@ -128,6 +134,58 @@ describe('recipient selection through generated queries', () => {
|
||||
expect(field()).toHaveFocus()
|
||||
})
|
||||
|
||||
it('prevents adding Initiator with the pointer or Enter in a trigger workflow', async () => {
|
||||
contactApi.options.mockResolvedValue({ data: [], total: 0, page: 1, limit: 20 })
|
||||
const user = userEvent.setup()
|
||||
const observe = vi.fn()
|
||||
renderContactOwner(<Harness isTriggerMode observe={observe} />)
|
||||
await openPicker(user)
|
||||
|
||||
const initiator = await screen.findByRole('option', { name: initiatorLabel })
|
||||
expect(initiator).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(initiator).toHaveAccessibleDescription(initiatorUnavailableMessage)
|
||||
expect(within(initiator).getByText(initiatorUnavailableMessage)).toBeVisible()
|
||||
await user.click(initiator)
|
||||
expect(observe).not.toHaveBeenCalled()
|
||||
|
||||
await user.type(field(), 'initiator')
|
||||
await screen.findByText(/recipients\.resultCount/)
|
||||
await user.keyboard('{Enter}')
|
||||
expect(observe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps Initiator selectable with Enter in a manual workflow', async () => {
|
||||
contactApi.options.mockResolvedValue({ data: [], total: 0, page: 1, limit: 20 })
|
||||
const user = userEvent.setup()
|
||||
const observe = vi.fn()
|
||||
renderContactOwner(<Harness observe={observe} />)
|
||||
|
||||
await user.type(await screen.findByRole('combobox'), 'initiator')
|
||||
const initiator = await screen.findByRole('option', { name: initiatorLabel })
|
||||
expect(initiator).not.toHaveAttribute('aria-disabled', 'true')
|
||||
await screen.findByText(/recipients\.resultCount/)
|
||||
await user.keyboard('{Enter}')
|
||||
expect(observe).toHaveBeenLastCalledWith([{ type: 'initiator' }])
|
||||
})
|
||||
|
||||
it('keeps an existing Initiator visible and removable in a trigger workflow', async () => {
|
||||
const user = userEvent.setup()
|
||||
const observe = vi.fn()
|
||||
renderContactOwner(
|
||||
<Harness initial={[{ type: 'initiator' }]} isTriggerMode observe={observe} />,
|
||||
)
|
||||
|
||||
const initiator = screen.getByLabelText(initiatorLabel)
|
||||
expect(initiator).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(initiator).toHaveAccessibleDescription(initiatorUnavailableMessage)
|
||||
expect(screen.getByText(initiatorUnavailableMessage)).toBeVisible()
|
||||
expect(observe).not.toHaveBeenCalled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /recipients.remove.*initiator/ }))
|
||||
expect(observe).toHaveBeenLastCalledWith([])
|
||||
expect(screen.queryByText(initiatorUnavailableMessage)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'gates the platform source on authoritative features (%s)',
|
||||
async (enabled) => {
|
||||
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
'use client'
|
||||
|
||||
import type { RangeSelection } from 'lexical'
|
||||
import type { AriaAttributes } from 'react'
|
||||
import type { Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Field } from '@langgenius/dify-ui/field'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
$getNodeByKey,
|
||||
$getRoot,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
$setSelection,
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
HISTORY_PUSH_TAG,
|
||||
INSERT_LINE_BREAK_COMMAND,
|
||||
INSERT_PARAGRAPH_COMMAND,
|
||||
KEY_ENTER_COMMAND,
|
||||
PASTE_COMMAND,
|
||||
} from 'lexical'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import { INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/workflow-variable-block'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import VarReferenceVars from '../../_base/components/variable/var-reference-vars'
|
||||
|
||||
const normalizeSubject = (value: string) => value.replace(/[\r\n\u2028\u2029]+/g, ' ')
|
||||
|
||||
// Subject owns its single-line editing rules and the selection used by its toolbar picker.
|
||||
const SubjectEditorControls = ({
|
||||
disabled,
|
||||
availableVars,
|
||||
}: {
|
||||
disabled: boolean
|
||||
availableVars: NodeOutPutVar[]
|
||||
}) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const { t } = useTranslation(['workflow'])
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const inputRef = useRef<HTMLElement | null>(null)
|
||||
const selectionRef = useRef<RangeSelection | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const insertVariableLabel = t(($) => $['nodes.humanInputV2.template.insertVariable'], {
|
||||
ns: 'workflow',
|
||||
})
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
mergeRegister(
|
||||
editor.registerRootListener((root) => {
|
||||
inputRef.current = root
|
||||
}),
|
||||
editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) selectionRef.current = selection.clone()
|
||||
})
|
||||
}),
|
||||
editor.registerCommand(
|
||||
KEY_ENTER_COMMAND,
|
||||
(event) => {
|
||||
if (event?.isComposing) return false
|
||||
event?.preventDefault()
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
),
|
||||
editor.registerCommand(INSERT_PARAGRAPH_COMMAND, () => true, COMMAND_PRIORITY_HIGH),
|
||||
editor.registerCommand(INSERT_LINE_BREAK_COMMAND, () => true, COMMAND_PRIORITY_HIGH),
|
||||
editor.registerCommand(
|
||||
PASTE_COMMAND,
|
||||
(event) => {
|
||||
if (!editor.isEditable() || !event || !('clipboardData' in event)) return false
|
||||
const selection = $getSelection()
|
||||
if (!$isRangeSelection(selection) || !event.clipboardData) return false
|
||||
event.preventDefault()
|
||||
selection.insertText(normalizeSubject(event.clipboardData.getData('text/plain')))
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
),
|
||||
),
|
||||
[editor],
|
||||
)
|
||||
|
||||
const insertVariable = (selector: ValueSelector) => {
|
||||
if (disabled || !editor.isEditable()) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
editor.update(
|
||||
() => {
|
||||
if (!editor.isEditable()) return
|
||||
const previous = selectionRef.current
|
||||
if (
|
||||
previous &&
|
||||
$getNodeByKey(previous.anchor.key)?.isAttached() &&
|
||||
$getNodeByKey(previous.focus.key)?.isAttached()
|
||||
)
|
||||
$setSelection(previous.clone())
|
||||
else $getRoot().selectEnd()
|
||||
editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, selector)
|
||||
},
|
||||
{ tag: HISTORY_PUSH_TAG },
|
||||
)
|
||||
setOpen(false)
|
||||
editor.focus(undefined, { defaultSelection: 'rootEnd' })
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nextOpen && disabled) return
|
||||
setOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<IconButton
|
||||
className="absolute end-1 top-1/2 -translate-y-1/2"
|
||||
type="button"
|
||||
size="xs"
|
||||
aria-label={insertVariableLabel}
|
||||
title={insertVariableLabel}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span aria-hidden="true" className="i-custom-vender-workflow-variable-x size-4" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
aria-label={insertVariableLabel}
|
||||
align="end"
|
||||
className="w-75 p-1"
|
||||
initialFocus={searchInputRef}
|
||||
finalFocus={inputRef}
|
||||
>
|
||||
{/* The picker search must not inherit the Subject field's id, label, or validation. */}
|
||||
<Field className="contents">
|
||||
<VarReferenceVars
|
||||
vars={availableVars}
|
||||
searchInputRef={searchInputRef}
|
||||
maxHeightClass="max-h-64"
|
||||
onChange={insertVariable}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</Field>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
type MessageTemplateSubjectProps = Pick<
|
||||
AriaAttributes,
|
||||
'aria-labelledby' | 'aria-describedby' | 'aria-invalid'
|
||||
> & {
|
||||
id: string
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
availableVars: NodeOutPutVar[]
|
||||
availableNodes: Node[]
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const MessageTemplateSubject = ({
|
||||
value,
|
||||
onValueChange,
|
||||
availableVars,
|
||||
availableNodes,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
...props
|
||||
}: MessageTemplateSubjectProps) => {
|
||||
const { t } = useTranslation(['workflow'])
|
||||
const workflowNodesMap = useMemo(
|
||||
() =>
|
||||
availableNodes.reduce(
|
||||
(acc, node) => {
|
||||
acc[node.id] = { title: node.data.title, type: node.data.type }
|
||||
if (node.data.type === BlockEnum.Start)
|
||||
acc.sys = {
|
||||
title: t(($) => $['blocks.start'], { ns: 'workflow' }),
|
||||
type: BlockEnum.Start,
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, Pick<Node['data'], 'title' | 'type'>>,
|
||||
),
|
||||
[availableNodes, t],
|
||||
)
|
||||
|
||||
return (
|
||||
<PromptEditor
|
||||
{...props}
|
||||
wrapperClassName={cn(
|
||||
'min-h-8 w-full rounded-lg border border-transparent bg-components-input-bg-normal py-1 ps-2 pe-8',
|
||||
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
|
||||
'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
className="min-h-6 break-words [&_[data-lexical-decorator]]:h-lh [&_[data-lexical-decorator]]:align-top"
|
||||
placeholderClassName="px-2 py-1 pe-8"
|
||||
placeholder={placeholder}
|
||||
editable={!disabled}
|
||||
aria-disabled={disabled}
|
||||
aria-multiline={false}
|
||||
value={normalizeSubject(value)}
|
||||
onChange={(text) => {
|
||||
const nextValue = normalizeSubject(text)
|
||||
if (nextValue !== normalizeSubject(value)) onValueChange(nextValue)
|
||||
}}
|
||||
workflowVariableBlock={{ show: true, variables: availableVars, workflowNodesMap }}
|
||||
disableSlashPicker
|
||||
disableBracePicker
|
||||
>
|
||||
<SubjectEditorControls disabled={disabled} availableVars={availableVars} />
|
||||
</PromptEditor>
|
||||
)
|
||||
}
|
||||
|
||||
export default MessageTemplateSubject
|
||||
@@ -49,6 +49,7 @@ import { isOutput } from '../../human-input/utils'
|
||||
import { getRequestURLCount } from '../message-template-utils'
|
||||
import useConfiguredChannels from '../use-configured-channels'
|
||||
import MessageTemplateBody from './message-template-body'
|
||||
import MessageTemplateSubject from './message-template-subject'
|
||||
|
||||
const getTemplateVariableType = (selector: ValueSelector, availableVars: NodeOutPutVar[]) => {
|
||||
const vars = availableVars.find((item) => item.nodeId === selector[0])?.vars
|
||||
@@ -287,14 +288,22 @@ const MessageTemplateSession = ({
|
||||
<DialogBody className="min-h-65 space-y-5">
|
||||
<Field name="subject" invalid={errors.subject && !draft.subject.trim()}>
|
||||
<FieldLabel
|
||||
id={`${nodeId}-message-subject-label`}
|
||||
htmlFor={`${nodeId}-message-subject`}
|
||||
onClick={() => {
|
||||
if (!readonly && !pending)
|
||||
document.getElementById(`${nodeId}-message-subject`)?.focus()
|
||||
}}
|
||||
className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary"
|
||||
>
|
||||
{t(($) => $['nodes.humanInputV2.template.subject'], { ns: 'workflow' })}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
<MessageTemplateSubject
|
||||
id={`${nodeId}-message-subject`}
|
||||
value={draft.subject}
|
||||
availableVars={availableVars}
|
||||
availableNodes={availableNodes}
|
||||
aria-labelledby={`${nodeId}-message-subject-label`}
|
||||
disabled={readonly || pending}
|
||||
aria-invalid={errors.subject && !draft.subject.trim()}
|
||||
aria-describedby={
|
||||
@@ -302,9 +311,9 @@ const MessageTemplateSession = ({
|
||||
? `${nodeId}-message-subject-error`
|
||||
: undefined
|
||||
}
|
||||
onChange={(event) => {
|
||||
onValueChange={(subject) => {
|
||||
resetTestResult()
|
||||
setDraft((current) => ({ ...current, subject: event.target.value }))
|
||||
setDraft((current) => ({ ...current, subject }))
|
||||
}}
|
||||
placeholder={t(($) => $['nodes.humanInputV2.template.subjectPlaceholder'], {
|
||||
ns: 'workflow',
|
||||
|
||||
@@ -59,6 +59,7 @@ type RecipientsProps = {
|
||||
value: HumanInputV2Recipient[]
|
||||
onChange: (value: HumanInputV2Recipient[]) => void
|
||||
readonly: boolean
|
||||
isTriggerMode: boolean
|
||||
availableNodes?: Node[]
|
||||
}
|
||||
type SourceFilter = 'all' | 'workspace' | 'organization' | 'external'
|
||||
@@ -77,6 +78,7 @@ const RecipientsContent = ({
|
||||
value,
|
||||
onChange,
|
||||
readonly,
|
||||
isTriggerMode,
|
||||
availableNodes = [],
|
||||
workspaceId,
|
||||
workspace,
|
||||
@@ -221,6 +223,13 @@ const RecipientsContent = ({
|
||||
])
|
||||
const selectedContacts = selectedItems.flatMap((item) => (item.contact ? [item.contact] : []))
|
||||
const selectedKeys = selectedItems.map((item) => item.key)
|
||||
const isUnavailableInitiator = (recipient: HumanInputV2Recipient) =>
|
||||
isTriggerMode && recipient.type === 'initiator'
|
||||
const initiatorUnavailableMessage = t(
|
||||
($) => $['nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow'],
|
||||
{ ns: 'workflow' },
|
||||
)
|
||||
const initiatorUnavailableId = `${nodeId}-initiator-unavailable`
|
||||
const previewConflict =
|
||||
previewContact &&
|
||||
findConflictingContact(
|
||||
@@ -312,6 +321,10 @@ const RecipientsContent = ({
|
||||
if (added) {
|
||||
const item = itemMap.get(added)
|
||||
if (!item) return
|
||||
if (isUnavailableInitiator(item.recipient)) {
|
||||
details.cancel()
|
||||
return
|
||||
}
|
||||
if (
|
||||
item.contact &&
|
||||
findConflictingContact(
|
||||
@@ -342,7 +355,9 @@ const RecipientsContent = ({
|
||||
>
|
||||
{selectedItems.map((item, index) => {
|
||||
const recipient = item.recipient
|
||||
const initiatorUnavailable = isUnavailableInitiator(recipient)
|
||||
const invalid =
|
||||
initiatorUnavailable ||
|
||||
!!getRecipientValidationError(recipient) ||
|
||||
value.findIndex(
|
||||
(other) =>
|
||||
@@ -375,7 +390,11 @@ const RecipientsContent = ({
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={
|
||||
[
|
||||
invalid ? `${nodeId}-invalid-${index}` : undefined,
|
||||
initiatorUnavailable
|
||||
? initiatorUnavailableId
|
||||
: invalid
|
||||
? `${nodeId}-invalid-${index}`
|
||||
: undefined,
|
||||
recipient.type === 'contact' && !item.contact
|
||||
? `${nodeId}-contact-${index}`
|
||||
: undefined,
|
||||
@@ -438,7 +457,7 @@ const RecipientsContent = ({
|
||||
workspace.contactCount !== undefined && (
|
||||
<span>{workspace.contactCount}</span>
|
||||
)}
|
||||
{invalid && (
|
||||
{invalid && !initiatorUnavailable && (
|
||||
<span id={`${nodeId}-invalid-${index}`} className="sr-only">
|
||||
{invalidMessage}
|
||||
</span>
|
||||
@@ -513,6 +532,7 @@ const RecipientsContent = ({
|
||||
item.contact?.email?.toLocaleLowerCase() ===
|
||||
query.trim().toLocaleLowerCase()),
|
||||
)
|
||||
if (candidate && isUnavailableInitiator(candidate.recipient)) return
|
||||
if (
|
||||
candidate &&
|
||||
(!candidate.contact ||
|
||||
@@ -537,6 +557,11 @@ const RecipientsContent = ({
|
||||
<p id={`${nodeId}-selection-hint`} className="sr-only">
|
||||
{inputHint}
|
||||
</p>
|
||||
{value.some(isUnavailableInitiator) && (
|
||||
<p id={initiatorUnavailableId} className="px-1 system-xs-regular text-text-destructive">
|
||||
{initiatorUnavailableMessage}
|
||||
</p>
|
||||
)}
|
||||
{emailError && (
|
||||
<p
|
||||
id={`${nodeId}-recipient-email-error`}
|
||||
@@ -723,6 +748,7 @@ const RecipientsContent = ({
|
||||
</ComboboxStatus>
|
||||
<ComboboxList<RecipientItem>>
|
||||
{(item) => {
|
||||
const initiatorUnavailable = isUnavailableInitiator(item.recipient)
|
||||
const added = value.some(
|
||||
(recipient) =>
|
||||
getRecipientCanonicalKey(recipient) ===
|
||||
@@ -742,8 +768,16 @@ const RecipientsContent = ({
|
||||
<ComboboxItem<string>
|
||||
key={item.key}
|
||||
value={item.key}
|
||||
disabled={added || !!conflict}
|
||||
className="min-w-0 grow"
|
||||
disabled={initiatorUnavailable || added || !!conflict}
|
||||
aria-label={initiatorUnavailable ? item.label : undefined}
|
||||
aria-describedby={
|
||||
initiatorUnavailable ? `${initiatorUnavailableId}-option` : undefined
|
||||
}
|
||||
className={
|
||||
initiatorUnavailable
|
||||
? 'min-w-0 grow data-disabled:opacity-100'
|
||||
: 'min-w-0 grow'
|
||||
}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{item.contact && (
|
||||
@@ -755,9 +789,23 @@ const RecipientsContent = ({
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate system-sm-medium">
|
||||
<span
|
||||
className={
|
||||
initiatorUnavailable
|
||||
? 'block truncate system-sm-medium text-text-disabled'
|
||||
: 'block truncate system-sm-medium'
|
||||
}
|
||||
>
|
||||
{item.contact?.name ?? item.label}
|
||||
</span>
|
||||
{initiatorUnavailable && (
|
||||
<span
|
||||
id={`${initiatorUnavailableId}-option`}
|
||||
className="block system-xs-regular wrap-break-word text-text-tertiary"
|
||||
>
|
||||
{initiatorUnavailableMessage}
|
||||
</span>
|
||||
)}
|
||||
{item.contact?.email && (
|
||||
<span className="block truncate system-xs-regular text-text-tertiary">
|
||||
{item.contact.email}
|
||||
|
||||
@@ -4,7 +4,9 @@ import { Separator } from '@langgenius/dify-ui/separator'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
|
||||
import HumanInputSharedPanelSections from '@/app/components/workflow/nodes/human-input/shared/panel-sections'
|
||||
import useWorkflowNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import { isTriggerWorkflow } from '@/app/components/workflow/utils/workflow-entry'
|
||||
import { ENABLE_HITL_IM } from '@/config'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import DebugMode from './components/debug-mode'
|
||||
@@ -15,6 +17,7 @@ import useHumanInputV2Config from './use-config'
|
||||
|
||||
const HumanInputV2PanelContent = ({ id, data }: NodePanelProps<HumanInputV2NodeType>) => {
|
||||
const config = useHumanInputV2Config(id, data)
|
||||
const nodes = useWorkflowNodes()
|
||||
const { data: email } = useQuery({
|
||||
...userProfileQueryOptions(),
|
||||
select: (data) => data.profile.email,
|
||||
@@ -40,6 +43,7 @@ const HumanInputV2PanelContent = ({ id, data }: NodePanelProps<HumanInputV2NodeT
|
||||
value={config.inputs.recipients_spec}
|
||||
onChange={config.handleRecipientsChange}
|
||||
readonly={config.readOnly}
|
||||
isTriggerMode={isTriggerWorkflow(nodes)}
|
||||
availableNodes={availableNodesWithParent}
|
||||
/>
|
||||
<div className="px-4 py-2">
|
||||
|
||||
+320
-2
@@ -1,10 +1,14 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { NodeOutPutVar } from '../../../../types'
|
||||
import { act, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createNode } from '../../../../__tests__/fixtures'
|
||||
import { renderWorkflowComponent } from '../../../../__tests__/workflow-test-env'
|
||||
import { BlockEnum, InputVarType, VarType } from '../../../../types'
|
||||
import FormContentPreview, { FormContentPreviewTrigger } from '../form-content-preview'
|
||||
|
||||
const preview = vi.hoisted(() => vi.fn())
|
||||
const chatPreview = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/service/console', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/console')>()
|
||||
const { withConsoleClientResponses } = await import('@/test/console/service-responses')
|
||||
@@ -12,6 +16,8 @@ vi.mock('@/service/console', async (importOriginal) => {
|
||||
...actual,
|
||||
consoleClient: withConsoleClientResponses(actual.consoleClient, {
|
||||
'apps.byAppId.workflows.draft.humanInput.nodes.byNodeId.form.preview.post': preview,
|
||||
'apps.byAppId.advancedChat.workflows.draft.humanInput.nodes.byNodeId.form.preview.post':
|
||||
chatPreview,
|
||||
}),
|
||||
}
|
||||
})
|
||||
@@ -26,22 +32,57 @@ const props = {
|
||||
const renderPreview = (
|
||||
options: Parameters<typeof renderWorkflowComponent>[1] = {},
|
||||
readOnly = false,
|
||||
overrides: Partial<ComponentProps<typeof FormContentPreviewTrigger>> = {},
|
||||
) =>
|
||||
renderWorkflowComponent(
|
||||
readOnly ? (
|
||||
<FormContentPreview {...props} readOnly />
|
||||
<FormContentPreview {...props} {...overrides} readOnly />
|
||||
) : (
|
||||
<FormContentPreviewTrigger {...props} />
|
||||
<FormContentPreviewTrigger {...props} {...overrides} />
|
||||
),
|
||||
{
|
||||
...options,
|
||||
initialStoreState: {
|
||||
panelWidth: 320,
|
||||
nodes: [createNode({ id: 'node-1', data: { title: 'Classifier' } })],
|
||||
...options.initialStoreState,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const previewLabel = 'workflowHumanInput.nodes.humanInput.formContent.preview'
|
||||
const savedDraft = async () => ({ hash: 'saved', updatedAt: 1 })
|
||||
const sourceNode = createNode({
|
||||
id: 'source',
|
||||
data: { title: 'Contract source', type: BlockEnum.Code },
|
||||
})
|
||||
const sourceVars: NodeOutPutVar = {
|
||||
nodeId: 'source',
|
||||
title: 'Contract source',
|
||||
vars: [
|
||||
{ variable: 'draft', type: VarType.string },
|
||||
{
|
||||
variable: 'object',
|
||||
type: VarType.object,
|
||||
children: [{ variable: 'name', type: VarType.string }],
|
||||
},
|
||||
{ variable: 'amount', type: VarType.number },
|
||||
{ variable: 'approved', type: VarType.boolean },
|
||||
{ variable: 'settings', type: VarType.object },
|
||||
{ variable: 'options', type: VarType.arrayString },
|
||||
],
|
||||
}
|
||||
|
||||
const submitPreview = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
const form = screen.getByRole('form', { name: previewLabel })
|
||||
await user.click(within(form).getByRole('button', { name: previewLabel }))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
preview.mockReset()
|
||||
chatPreview.mockReset()
|
||||
})
|
||||
|
||||
it('waits for the saved workflow before displaying the real server markdown', async () => {
|
||||
let finishSave!: () => void
|
||||
const saving = new Promise<{ hash: string; updatedAt: number }>((resolve) => {
|
||||
@@ -100,3 +141,280 @@ it('renders historical readonly content without loading the current draft', asyn
|
||||
expect(preview).not.toHaveBeenCalled()
|
||||
expect(sync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ mode: 'workflow' as const, selector: 'source.draft', field: /draft/ },
|
||||
{ mode: 'advanced-chat' as const, selector: 'source.object.name', field: /name/ },
|
||||
])(
|
||||
'collects missing dependency values before requesting a $mode preview',
|
||||
async ({ mode, selector, field }) => {
|
||||
const user = userEvent.setup()
|
||||
const endpoint = mode === 'workflow' ? preview : chatPreview
|
||||
endpoint.mockResolvedValue({
|
||||
form_content: 'Server-resolved contract',
|
||||
inputs: [],
|
||||
actions: [],
|
||||
})
|
||||
renderPreview(
|
||||
{
|
||||
resource: { kind: 'app', appId: 'app-1', mode },
|
||||
hooksStoreProps: { doSyncWorkflowDraft: savedDraft },
|
||||
},
|
||||
false,
|
||||
{
|
||||
content: `Review {{#${selector}#}} {{#$output.feedback#}}`,
|
||||
availableVars: [sourceVars],
|
||||
availableNodes: [sourceNode],
|
||||
},
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: previewLabel }))
|
||||
const input = await screen.findByRole('textbox', { name: field })
|
||||
expect(endpoint).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('textbox', { name: /feedback/ })).not.toBeInTheDocument()
|
||||
await user.type(input, 'Reviewed contract')
|
||||
await submitPreview(user)
|
||||
|
||||
expect(await screen.findByText('Server-resolved contract')).toBeInTheDocument()
|
||||
expect(endpoint).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
params: { app_id: 'app-1', node_id: 'node-1' },
|
||||
body: { inputs: { [`#${selector}#`]: 'Reviewed contract' } },
|
||||
},
|
||||
{ context: { silent: true } },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ selector: 'source.amount', value: 0 },
|
||||
{ selector: 'source.approved', value: false },
|
||||
{ selector: 'source.draft', value: '' },
|
||||
])(
|
||||
'uses a cached dependency value of $value without requesting it again',
|
||||
async ({ selector, value }) => {
|
||||
const cachedValues = new Map<string, unknown>([[selector, value]])
|
||||
preview.mockResolvedValue({ form_content: 'Cached server preview', inputs: [], actions: [] })
|
||||
renderPreview(
|
||||
{
|
||||
hooksStoreProps: {
|
||||
doSyncWorkflowDraft: savedDraft,
|
||||
hasSetInspectVar: (nodeId, name) => cachedValues.has(`${nodeId}.${name}`),
|
||||
},
|
||||
},
|
||||
false,
|
||||
{
|
||||
content: `{{#${selector}#}}`,
|
||||
availableVars: [sourceVars],
|
||||
availableNodes: [sourceNode],
|
||||
},
|
||||
)
|
||||
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: previewLabel }))
|
||||
expect(await screen.findByText('Cached server preview')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('form', { name: previewLabel })).not.toBeInTheDocument()
|
||||
expect(preview).toHaveBeenCalledExactlyOnceWith(
|
||||
{ params: { app_id: 'app-1', node_id: 'node-1' }, body: { inputs: {} } },
|
||||
{ context: { silent: true } },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
it('preserves typed dependency values when retrying a failed preview', async () => {
|
||||
const user = userEvent.setup()
|
||||
preview
|
||||
.mockRejectedValueOnce(new Error('Server preview failed'))
|
||||
.mockResolvedValue({ form_content: 'Retried with contract', inputs: [], actions: [] })
|
||||
renderPreview({ hooksStoreProps: { doSyncWorkflowDraft: savedDraft } }, false, {
|
||||
content: '{{#source.draft#}}',
|
||||
availableVars: [sourceVars],
|
||||
availableNodes: [sourceNode],
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: previewLabel }))
|
||||
await user.type(await screen.findByRole('textbox', { name: /draft/ }), 'Retained contract')
|
||||
await submitPreview(user)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('common.error')
|
||||
expect(screen.getByRole('textbox', { name: /draft/ })).toHaveValue('Retained contract')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
|
||||
|
||||
expect(await screen.findByText('Retried with contract')).toBeInTheDocument()
|
||||
expect(preview).toHaveBeenCalledTimes(2)
|
||||
for (const [request] of preview.mock.calls) {
|
||||
expect(request.body).toEqual({ inputs: { '#source.draft#': 'Retained contract' } })
|
||||
}
|
||||
})
|
||||
|
||||
it('lets the user supply dependency values when cached data cannot render the server preview', async () => {
|
||||
const user = userEvent.setup()
|
||||
preview
|
||||
.mockRejectedValueOnce(new Error('Cached value is not available'))
|
||||
.mockResolvedValue({ form_content: 'Manual server preview', inputs: [], actions: [] })
|
||||
renderPreview(
|
||||
{
|
||||
hooksStoreProps: { doSyncWorkflowDraft: savedDraft, hasSetInspectVar: () => true },
|
||||
},
|
||||
false,
|
||||
{
|
||||
content: '{{#source.draft#}}',
|
||||
availableVars: [sourceVars],
|
||||
availableNodes: [sourceNode],
|
||||
},
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: previewLabel }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('common.error')
|
||||
await user.type(await screen.findByRole('textbox', { name: /draft/ }), 'Manual contract')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
|
||||
expect(await screen.findByText('Manual server preview')).toBeInTheDocument()
|
||||
expect(preview.mock.calls[1]?.[0].body).toEqual({
|
||||
inputs: { '#source.draft#': 'Manual contract' },
|
||||
})
|
||||
})
|
||||
|
||||
it('submits number, boolean and JSON dependency values using their declared types', async () => {
|
||||
const user = userEvent.setup()
|
||||
preview.mockResolvedValue({ form_content: 'Typed server preview', inputs: [], actions: [] })
|
||||
renderPreview({ hooksStoreProps: { doSyncWorkflowDraft: savedDraft } }, false, {
|
||||
content: '{{#source.amount#}} {{#source.approved#}} {{#source.settings#}}',
|
||||
availableVars: [sourceVars],
|
||||
availableNodes: [sourceNode],
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: previewLabel }))
|
||||
await user.type(await screen.findByLabelText(/amount/), '0')
|
||||
expect(screen.getByRole('checkbox')).not.toBeChecked()
|
||||
await user.click(screen.getByTestId('monaco-editor'))
|
||||
await user.paste('{"term":30}')
|
||||
await submitPreview(user)
|
||||
|
||||
expect(await screen.findByText('Typed server preview')).toBeInTheDocument()
|
||||
expect(preview).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
params: { app_id: 'app-1', node_id: 'node-1' },
|
||||
body: {
|
||||
inputs: {
|
||||
'#source.amount#': 0,
|
||||
'#source.approved#': false,
|
||||
'#source.settings#': { term: 30 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{ context: { silent: true } },
|
||||
)
|
||||
})
|
||||
|
||||
it('collects variable defaults and select options without treating output fields as dependencies', async () => {
|
||||
const user = userEvent.setup()
|
||||
preview.mockResolvedValue({ form_content: 'Server field defaults', inputs: [], actions: [] })
|
||||
renderPreview({ hooksStoreProps: { doSyncWorkflowDraft: savedDraft } }, false, {
|
||||
content: '{{#$output.feedback#}} {{#$output.choice#}}',
|
||||
formInputs: [
|
||||
{
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'feedback',
|
||||
default: { type: 'variable', selector: ['source', 'draft'], value: '' },
|
||||
},
|
||||
{
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'choice',
|
||||
option_source: { type: 'variable', selector: ['source', 'options'], value: [] },
|
||||
},
|
||||
],
|
||||
availableVars: [sourceVars],
|
||||
availableNodes: [sourceNode],
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: previewLabel }))
|
||||
await user.type(await screen.findByRole('textbox', { name: /draft/ }), 'Default feedback')
|
||||
expect(screen.queryByRole('textbox', { name: /feedback/ })).not.toBeInTheDocument()
|
||||
await user.click(screen.getByTestId('monaco-editor'))
|
||||
await user.paste('["approve","revise"]')
|
||||
await submitPreview(user)
|
||||
|
||||
expect(await screen.findByText('Server field defaults')).toBeInTheDocument()
|
||||
expect(preview).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
params: { app_id: 'app-1', node_id: 'node-1' },
|
||||
body: {
|
||||
inputs: {
|
||||
'#source.draft#': 'Default feedback',
|
||||
'#source.options#': ['approve', 'revise'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ context: { silent: true } },
|
||||
)
|
||||
})
|
||||
|
||||
it('shows an inline required-field error before requesting a preview and accepts the corrected value', async () => {
|
||||
const user = userEvent.setup()
|
||||
const sync = vi.fn(savedDraft)
|
||||
preview.mockResolvedValue({ form_content: 'Validated server preview', inputs: [], actions: [] })
|
||||
renderPreview({ hooksStoreProps: { doSyncWorkflowDraft: sync } }, false, {
|
||||
content: '{{#source.draft#}}',
|
||||
availableVars: [sourceVars],
|
||||
availableNodes: [sourceNode],
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: previewLabel }))
|
||||
const form = await screen.findByRole('form', { name: previewLabel })
|
||||
await submitPreview(user)
|
||||
expect(within(form).getByRole('alert')).toHaveTextContent('workflow.errorMsg.fieldRequired')
|
||||
expect(sync).not.toHaveBeenCalled()
|
||||
expect(preview).not.toHaveBeenCalled()
|
||||
|
||||
await user.type(within(form).getByRole('textbox', { name: /draft/ }), 'Validated contract')
|
||||
expect(within(form).queryByRole('alert')).not.toBeInTheDocument()
|
||||
await submitPreview(user)
|
||||
expect(await screen.findByText('Validated server preview')).toBeInTheDocument()
|
||||
expect(sync).toHaveBeenCalledTimes(1)
|
||||
expect(preview).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
params: { app_id: 'app-1', node_id: 'node-1' },
|
||||
body: { inputs: { '#source.draft#': 'Validated contract' } },
|
||||
},
|
||||
{ context: { silent: true } },
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores a closed preview request failure after reopening with different dependencies', async () => {
|
||||
const user = userEvent.setup()
|
||||
let failOldPreview!: (error: Error) => void
|
||||
preview.mockReturnValue(
|
||||
new Promise((_resolve, reject) => {
|
||||
failOldPreview = reject
|
||||
}),
|
||||
)
|
||||
const view = renderPreview(
|
||||
{
|
||||
hooksStoreProps: {
|
||||
doSyncWorkflowDraft: savedDraft,
|
||||
hasSetInspectVar: (nodeId, name) => nodeId === 'source' && name === 'draft',
|
||||
},
|
||||
},
|
||||
false,
|
||||
{
|
||||
content: '{{#source.draft#}}',
|
||||
availableVars: [sourceVars],
|
||||
availableNodes: [sourceNode],
|
||||
},
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: previewLabel }))
|
||||
expect(preview).toHaveBeenCalledTimes(1)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||
view.rerender(
|
||||
<FormContentPreviewTrigger
|
||||
{...props}
|
||||
content="{{#source.amount#}}"
|
||||
availableVars={[sourceVars]}
|
||||
availableNodes={[sourceNode]}
|
||||
/>,
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: previewLabel }))
|
||||
expect(await screen.findByLabelText(/amount/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('textbox', { name: /draft/ })).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => failOldPreview(new Error('The closed preview failed')))
|
||||
|
||||
expect(screen.getByLabelText(/amount/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('textbox', { name: /draft/ })).not.toBeInTheDocument()
|
||||
expect(preview).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
+56
-3
@@ -1,7 +1,7 @@
|
||||
import type { ComponentProps, ReactElement } from 'react'
|
||||
import { detectPlatform } from '@tanstack/react-hotkeys'
|
||||
import { ReactFlowProvider } from 'reactflow'
|
||||
import { userEvent } from 'vite-plus/test/browser'
|
||||
import { page, userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { createNode } from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
@@ -15,8 +15,7 @@ import { seedToolProviders } from '@/test/console/tool-providers'
|
||||
import { createDefaultParagraphFormInput } from '../../types'
|
||||
import FormContent from '../form-content'
|
||||
|
||||
it('preserves the shortcut draft and inserts at the editor selection after focus moves into the input form', async () => {
|
||||
// Native focus and selection must survive the portal and parent rerender; a callback stub cannot prove insertion.
|
||||
function createFormContentFixture() {
|
||||
const { queryClient, wrapper: QueryProvider } = createConsoleQueryWrapper()
|
||||
seedModelProviderQueries(queryClient)
|
||||
seedToolProviders(queryClient, {
|
||||
@@ -51,6 +50,12 @@ it('preserves the shortcut draft and inserts at the editor selection after focus
|
||||
</EditorResourceScope>
|
||||
</QueryProvider>
|
||||
)
|
||||
return { queryClient, props, wrap }
|
||||
}
|
||||
|
||||
it('preserves the shortcut draft and inserts at the editor selection after focus moves into the input form', async () => {
|
||||
// Native focus and selection must survive the portal and parent rerender; a callback stub cannot prove insertion.
|
||||
const { queryClient, props, wrap } = createFormContentFixture()
|
||||
const screen = await render(wrap(<FormContent {...props} />))
|
||||
const editor = screen.getByRole('textbox')
|
||||
await editor.click()
|
||||
@@ -83,3 +88,51 @@ it('preserves the shortcut draft and inserts at the editor selection after focus
|
||||
.toBeVisible()
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
it('restores field editing and removal after the mounted editor leaves readonly mode', async () => {
|
||||
// Native hover and clicks inside Lexical must reach the recreated action buttons without remounting the editor.
|
||||
const { queryClient, props, wrap } = createFormContentFixture()
|
||||
const field = createDefaultParagraphFormInput('notes')
|
||||
const fieldProps = {
|
||||
...props,
|
||||
value: '{{#$output.notes#}}',
|
||||
formInputs: [{ ...field, default: { ...field.default, value: 'Review notes' } }],
|
||||
}
|
||||
const subject = (readonly: boolean) =>
|
||||
wrap(
|
||||
<section aria-label="Form content" style={{ width: 480, padding: 24 }}>
|
||||
<FormContent {...fieldProps} readonly={readonly} />
|
||||
</section>,
|
||||
)
|
||||
const screen = await render(subject(false))
|
||||
const content = screen.getByRole('region', { name: 'Form content' })
|
||||
const edit = content.getByRole('button', { name: 'common.operation.edit' })
|
||||
const remove = content.getByRole('button', { name: 'common.operation.remove' })
|
||||
try {
|
||||
await content.getByText('Review notes', { exact: true }).hover()
|
||||
await edit.click()
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect.element(dialog).toBeVisible()
|
||||
await dialog.getByRole('button', { name: 'common.operation.cancel' }).click()
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
|
||||
await screen.rerender(subject(true))
|
||||
await expect.element(edit).not.toBeInTheDocument()
|
||||
await expect.element(remove).not.toBeInTheDocument()
|
||||
await screen.rerender(subject(false))
|
||||
await content.getByText('Review notes', { exact: true }).hover()
|
||||
await edit.click()
|
||||
await expect.element(dialog).toBeVisible()
|
||||
await expect
|
||||
.element(dialog.getByRole('textbox', { name: /saveResponseAs/ }))
|
||||
.toHaveValue('notes')
|
||||
await dialog.getByRole('button', { name: 'common.operation.cancel' }).click()
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
|
||||
await content.getByText('Review notes', { exact: true }).hover()
|
||||
await remove.click()
|
||||
expect(props.onFormInputItemRemove).toHaveBeenCalledExactlyOnceWith('notes')
|
||||
} finally {
|
||||
queryClient.clear()
|
||||
}
|
||||
})
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import type { Field } from '../../llm/types'
|
||||
import type { FormInputItem } from '../types'
|
||||
import type {
|
||||
InputVar,
|
||||
Node,
|
||||
NodeOutPutVar,
|
||||
ValueSelector,
|
||||
Var,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { getInputVars } from '@/app/components/base/prompt-editor/constants'
|
||||
import { InputVarType, VarType } from '@/app/components/workflow/types'
|
||||
import { isENV } from '../../_base/components/variable/utils'
|
||||
import { Type } from '../../llm/types'
|
||||
import { getHumanInputFormDependencySelectors, isOutput } from '../utils'
|
||||
|
||||
type PreviewInputParams = {
|
||||
content: string
|
||||
formInputs: FormInputItem[]
|
||||
availableVars: NodeOutPutVar[]
|
||||
availableNodes: Node[]
|
||||
}
|
||||
|
||||
const getSchemaVariable = (field: Field, path: string[]): Var => {
|
||||
const options = field.enum?.filter((value): value is string => typeof value === 'string')
|
||||
const type = (() => {
|
||||
switch (field.type) {
|
||||
case Type.number:
|
||||
return VarType.number
|
||||
case Type.boolean:
|
||||
return VarType.boolean
|
||||
case Type.object:
|
||||
return VarType.object
|
||||
case Type.file:
|
||||
return VarType.file
|
||||
case Type.array:
|
||||
return field.items?.type === Type.file ? VarType.arrayFile : VarType.array
|
||||
case Type.arrayString:
|
||||
return VarType.arrayString
|
||||
case Type.arrayNumber:
|
||||
return VarType.arrayNumber
|
||||
case Type.arrayObject:
|
||||
return VarType.arrayObject
|
||||
default:
|
||||
return VarType.string
|
||||
}
|
||||
})()
|
||||
return {
|
||||
variable: path.join('.'),
|
||||
type,
|
||||
isSelect: !!options?.length && options.length === field.enum?.length,
|
||||
options,
|
||||
}
|
||||
}
|
||||
|
||||
const findVariable = (variables: Var[], path: string[]): Var | undefined => {
|
||||
// Metadata can contain relative child names or flattened names such as sys.query.
|
||||
for (let length = path.length; length > 0; length--) {
|
||||
const variable = variables.find((item) => item.variable === path.slice(0, length).join('.'))
|
||||
if (!variable) continue
|
||||
if (length === path.length) return variable
|
||||
|
||||
const remainingPath = path.slice(length)
|
||||
if (Array.isArray(variable.children)) return findVariable(variable.children, remainingPath)
|
||||
if (!variable.children) continue
|
||||
|
||||
let properties = variable.children.schema.properties
|
||||
let field: Field | undefined
|
||||
for (const key of remainingPath) {
|
||||
field = properties?.[key]
|
||||
if (!field) break
|
||||
properties = field.properties ?? {}
|
||||
}
|
||||
if (field) return getSchemaVariable(field, remainingPath)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const getInputType = (variable?: Var): InputVarType => {
|
||||
if (variable?.isSelect) return InputVarType.select
|
||||
if (variable?.isParagraph) return InputVarType.paragraph
|
||||
switch (variable?.type) {
|
||||
case VarType.number:
|
||||
case VarType.integer:
|
||||
return InputVarType.number
|
||||
case VarType.boolean:
|
||||
return InputVarType.checkbox
|
||||
case VarType.object:
|
||||
case VarType.array:
|
||||
case VarType.arrayString:
|
||||
case VarType.arrayNumber:
|
||||
case VarType.arrayObject:
|
||||
case VarType.arrayBoolean:
|
||||
case VarType.arrayAny:
|
||||
return InputVarType.json
|
||||
case VarType.file:
|
||||
return InputVarType.singleFile
|
||||
case VarType.arrayFile:
|
||||
return InputVarType.multiFiles
|
||||
default:
|
||||
return InputVarType.textInput
|
||||
}
|
||||
}
|
||||
|
||||
export const getHumanInputPreviewInputs = ({
|
||||
content,
|
||||
formInputs,
|
||||
availableVars,
|
||||
availableNodes,
|
||||
}: PreviewInputParams): InputVar[] => {
|
||||
const selectors = new Map<string, ValueSelector>()
|
||||
for (const selector of [
|
||||
...getInputVars(content),
|
||||
...getHumanInputFormDependencySelectors(formInputs),
|
||||
]) {
|
||||
if (selector.length < 2 || isOutput(selector) || isENV(selector)) continue
|
||||
selectors.set(selector.join('.'), selector)
|
||||
}
|
||||
|
||||
return Array.from(selectors.values(), (selector) => {
|
||||
let group = availableVars.find((item) => item.nodeId === selector[0])
|
||||
let variable = group
|
||||
? (findVariable(group.vars, selector.slice(1)) ?? findVariable(group.vars, selector))
|
||||
: undefined
|
||||
if (!variable) {
|
||||
group = availableVars.find((item) => findVariable(item.vars, selector))
|
||||
variable = group ? findVariable(group.vars, selector) : undefined
|
||||
}
|
||||
const node = availableNodes.find((item) => item.id === selector[0])
|
||||
const path = selector.join('.')
|
||||
const nodeName = node?.data.title || group?.title || selector[0] || path
|
||||
const type = getInputType(variable)
|
||||
return {
|
||||
variable: `#${path}#`,
|
||||
value_selector: selector,
|
||||
label:
|
||||
node && type !== InputVarType.checkbox
|
||||
? {
|
||||
nodeType: node.data.type,
|
||||
nodeName,
|
||||
variable: selector.slice(1).join('.'),
|
||||
isChatVar: selector[0] === 'conversation',
|
||||
}
|
||||
: `${nodeName} · ${path}`,
|
||||
type,
|
||||
required: true,
|
||||
...(variable?.options ? { options: variable.options } : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { FormInputItem, UserAction } from '../types'
|
||||
import type { InputVar, Node, NodeOutPutVar } from '@/app/components/workflow/types'
|
||||
import { zFormInputConfig, zUserActionConfig } from '@dify/contracts/api/console/apps/zod.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
@@ -13,12 +14,20 @@ import { getButtonStyle } from '@/app/components/base/chat/chat/answer/human-inp
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { editorResourceAtom } from '@/app/components/workflow/editor-resource/state'
|
||||
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
|
||||
import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { consoleClient } from '@/service/console'
|
||||
import Form from '../../_base/components/before-run-form/form'
|
||||
import {
|
||||
buildSubmitData,
|
||||
getFormErrorMessage,
|
||||
isFilesLoaded,
|
||||
} from '../../_base/components/before-run-form/helpers'
|
||||
import { HumanInputDraftSyncError, syncHumanInputDraft } from '../shared/sync-draft'
|
||||
import { normalizeHumanInputFormInput } from '../shared/types'
|
||||
import { getHumanInputPreviewInputs } from './form-content-preview-inputs'
|
||||
import { Note, rehypeNotes, rehypeVariable, Variable } from './variable-in-markdown'
|
||||
|
||||
const NODE_ID_RE = /#([^#.]+)([.#])/g
|
||||
@@ -117,7 +126,10 @@ const FormContentPreview: FC<FormContentPreviewProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
type PreviewTriggerProps = Omit<FormContentPreviewProps, 'onClose' | 'readOnly'>
|
||||
type PreviewTriggerProps = Omit<FormContentPreviewProps, 'onClose' | 'readOnly'> & {
|
||||
availableVars?: NodeOutPutVar[]
|
||||
availableNodes?: Node[]
|
||||
}
|
||||
|
||||
const PreviewButton = ({
|
||||
open,
|
||||
@@ -146,15 +158,46 @@ const PreviewButton = ({
|
||||
)
|
||||
}
|
||||
|
||||
const ServerPreviewTrigger = ({ nodeId }: PreviewTriggerProps & { nodeId: string }) => {
|
||||
const { t } = useTranslation(['workflow', 'common'])
|
||||
const ServerPreviewTrigger = ({
|
||||
nodeId,
|
||||
content,
|
||||
formInputs,
|
||||
availableVars = [],
|
||||
availableNodes = [],
|
||||
}: PreviewTriggerProps & { nodeId: string }) => {
|
||||
const { t } = useTranslation(['appDebug', 'workflow', 'common', 'workflowHumanInput'])
|
||||
const resource = useAtomValue(editorResourceAtom)
|
||||
const { doSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { hasSetInspectVar, systemVars, conversationVars } = useInspectVarsCrud()
|
||||
const panelWidth = useStore((state) => state.panelWidth)
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [inputVars, setInputVars] = React.useState<InputVar[]>([])
|
||||
const [inputValues, setInputValues] = React.useState<Record<string, string>>({})
|
||||
const [formError, setFormError] = React.useState('')
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null)
|
||||
const previewSessionRef = React.useRef(0)
|
||||
const dependencyInputs = getHumanInputPreviewInputs({
|
||||
content,
|
||||
formInputs,
|
||||
availableVars,
|
||||
availableNodes,
|
||||
})
|
||||
const hasCachedValue = (input: InputVar) => {
|
||||
const selector = input.value_selector
|
||||
return !!selector && hasSetInspectVar(selector[0]!, selector[1]!, systemVars, conversationVars)
|
||||
}
|
||||
const forms = [
|
||||
{
|
||||
inputs: inputVars.map((input) => ({ ...input, required: !hasCachedValue(input) })),
|
||||
values: inputValues,
|
||||
onChange: (values: Record<string, string>) => {
|
||||
setInputValues(values)
|
||||
setFormError('')
|
||||
},
|
||||
},
|
||||
]
|
||||
const preview = useMutation({
|
||||
mutationFn: async () => {
|
||||
mutationFn: async ({ inputs }: { inputs: Record<string, unknown>; session: number }) => {
|
||||
if (resource.kind !== 'app') throw new Error('Missing application')
|
||||
await syncHumanInputDraft(doSyncWorkflowDraft)
|
||||
const api =
|
||||
@@ -164,7 +207,7 @@ const ServerPreviewTrigger = ({ nodeId }: PreviewTriggerProps & { nodeId: string
|
||||
const response = await api.preview.post(
|
||||
{
|
||||
params: { app_id: resource.appId, node_id: nodeId },
|
||||
body: { inputs: {} },
|
||||
body: { inputs },
|
||||
},
|
||||
{ context: { silent: true } },
|
||||
)
|
||||
@@ -179,17 +222,56 @@ const ServerPreviewTrigger = ({ nodeId }: PreviewTriggerProps & { nodeId: string
|
||||
}),
|
||||
}
|
||||
},
|
||||
onError: (_error, { session }) => {
|
||||
if (session !== previewSessionRef.current) return
|
||||
// A cached root may still lack a nested value. Let the user supply it instead of retrying empty inputs.
|
||||
if (dependencyInputs.length) setInputVars(dependencyInputs)
|
||||
},
|
||||
})
|
||||
const requestPreview = (inputs: Record<string, unknown>) => {
|
||||
preview.mutate({ inputs, session: previewSessionRef.current })
|
||||
}
|
||||
const submitPreview = () => {
|
||||
const error = getFormErrorMessage(forms, [{}], t)
|
||||
if (error) {
|
||||
setFormError(error)
|
||||
return
|
||||
}
|
||||
const { submitData, parseErrorJsonField } = buildSubmitData(forms)
|
||||
if (parseErrorJsonField) {
|
||||
setFormError(
|
||||
t(($) => $['errorMsg.invalidJson'], { ns: 'workflow', field: parseErrorJsonField }),
|
||||
)
|
||||
return
|
||||
}
|
||||
setFormError('')
|
||||
const cachedInputKeys = new Set(inputVars.filter(hasCachedValue).map((input) => input.variable))
|
||||
requestPreview(
|
||||
Object.fromEntries(
|
||||
Object.entries(submitData).filter(
|
||||
([key, value]) =>
|
||||
value !== undefined && (key in inputValues || !cachedInputKeys.has(key)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
const close = () => {
|
||||
previewSessionRef.current += 1
|
||||
setOpen(false)
|
||||
preview.reset()
|
||||
setInputVars([])
|
||||
setInputValues({})
|
||||
setFormError('')
|
||||
buttonRef.current?.focus()
|
||||
}
|
||||
const toggle = () => {
|
||||
if (open) close()
|
||||
else {
|
||||
previewSessionRef.current += 1
|
||||
setOpen(true)
|
||||
preview.mutate()
|
||||
const missingInputs = dependencyInputs.filter((input) => !hasCachedValue(input))
|
||||
setInputVars(missingInputs)
|
||||
if (!missingInputs.length) requestPreview({})
|
||||
}
|
||||
}
|
||||
return (
|
||||
@@ -203,7 +285,7 @@ const ServerPreviewTrigger = ({ nodeId }: PreviewTriggerProps & { nodeId: string
|
||||
<FormContentPreview {...preview.data} onClose={close} />
|
||||
) : (
|
||||
<div
|
||||
className="fixed top-28 z-10 w-150 rounded-2xl border border-components-panel-border bg-components-panel-bg p-4 shadow-xl"
|
||||
className="fixed top-28 z-10 max-h-[calc(100vh-116px)] w-150 overflow-y-auto rounded-2xl border border-components-panel-border bg-components-panel-bg p-4 shadow-xl"
|
||||
style={{ right: panelWidth + 8 }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
@@ -213,6 +295,12 @@ const ServerPreviewTrigger = ({ nodeId }: PreviewTriggerProps & { nodeId: string
|
||||
? t(($) => $['nodes.humanInputV2.template.testSaveFailed'], { ns: 'workflow' })
|
||||
: t(($) => $.error, { ns: 'common' })}
|
||||
</p>
|
||||
) : inputVars.length ? (
|
||||
<p className="system-sm-semibold text-text-secondary">
|
||||
{t(($) => $['nodes.humanInput.formContent.preview'], {
|
||||
ns: 'workflowHumanInput',
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<SkeletonContainer aria-hidden className="grow">
|
||||
<SkeletonRectangle className="h-4 w-1/3" />
|
||||
@@ -226,8 +314,45 @@ const ServerPreviewTrigger = ({ nodeId }: PreviewTriggerProps & { nodeId: string
|
||||
<span aria-hidden className="i-ri-close-line size-5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{preview.isError && (
|
||||
<Button className="mt-3" onClick={() => preview.mutate()}>
|
||||
{!!inputVars.length && (
|
||||
<form
|
||||
aria-label={t(($) => $['nodes.humanInput.formContent.preview'], {
|
||||
ns: 'workflowHumanInput',
|
||||
})}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
submitPreview()
|
||||
}}
|
||||
>
|
||||
<fieldset disabled={preview.isPending} className="mt-3 min-w-0 border-0 p-0">
|
||||
<Form
|
||||
{...forms[0]!}
|
||||
label={t(($) => $['nodes.humanInput.singleRun.label'], {
|
||||
ns: 'workflowHumanInput',
|
||||
})}
|
||||
/>
|
||||
</fieldset>
|
||||
{formError && (
|
||||
<p role="alert" className="mt-2 text-text-destructive">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-3"
|
||||
loading={preview.isPending}
|
||||
disabled={preview.isPending || !isFilesLoaded(forms)}
|
||||
>
|
||||
{preview.isError
|
||||
? t(($) => $['operation.retry'], { ns: 'common' })
|
||||
: t(($) => $['nodes.humanInput.formContent.preview'], {
|
||||
ns: 'workflowHumanInput',
|
||||
})}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{preview.isError && !inputVars.length && (
|
||||
<Button className="mt-3" onClick={() => requestPreview({})}>
|
||||
{t(($) => $['operation.retry'], { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -130,6 +130,8 @@ const HumanInputSharedPanelSections = <T extends HumanInputSharedNodeType>({
|
||||
formInputs={inputs.inputs}
|
||||
userActions={inputs.user_actions}
|
||||
nodeId={isHumanInputV2NodeData(inputs) ? id : undefined}
|
||||
availableVars={availableVars}
|
||||
availableNodes={availableNodes}
|
||||
/>
|
||||
<div className="mx-2 h-3 w-px bg-divider-regular" />
|
||||
<div className="flex items-center space-x-1">
|
||||
|
||||
@@ -22,6 +22,7 @@ export const syncRun = (overrides: Partial<ImSyncRun> = {}): ImSyncRun => ({
|
||||
channel_id: channel.id,
|
||||
channel_config_version: 1,
|
||||
provider: 'feishu',
|
||||
initiator_display_name: '',
|
||||
started_at: 1,
|
||||
finished_at: 2,
|
||||
status: 'succeeded',
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
import { seedSystemFeatures } from '@/test/console/query-data'
|
||||
import { ContactsImPlatformRuntimeProvider } from '../composition'
|
||||
import { ContactsImPlatformManagementSurface } from '../management-surface'
|
||||
import { channelSummary, emailDetail } from './fixtures/channel-client'
|
||||
@@ -27,10 +29,11 @@ vi.mock('@/context/i18n', async (original) => ({
|
||||
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
|
||||
}))
|
||||
const clients: QueryClient[] = []
|
||||
function renderSurface(canManage = true) {
|
||||
function renderSurface(canManage = true, deploymentEdition: DeploymentEdition = 'CLOUD') {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
seedSystemFeatures(client, { deployment_edition: deploymentEdition })
|
||||
clients.push(client)
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
@@ -70,6 +73,25 @@ it('does not fetch or expose channels to a non-manager', () => {
|
||||
expect(network.handle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['available', 'configured'] as const)(
|
||||
'hides %s workspace Email configuration in Community Edition',
|
||||
async (emailState) => {
|
||||
const channels = [channel]
|
||||
if (emailState === 'configured')
|
||||
channels.push(channelSummary({ id: 'channel-email', kind: 'email', provider: 'resend' }))
|
||||
network.handle.mockImplementation((request) =>
|
||||
new URL(request.url).pathname.endsWith('/channels')
|
||||
? json({ channels })
|
||||
: defaultResponse(request),
|
||||
)
|
||||
|
||||
renderSurface(true, 'COMMUNITY')
|
||||
|
||||
expect(await screen.findByRole('group', { name: 'Feishu' })).toHaveTextContent('saved-tenant')
|
||||
expect(screen.queryByRole('group', { name: 'Email' })).not.toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
|
||||
it('uses saved redacted metadata for an update and keeps generated cache invalidation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const client = renderSurface()
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { page, userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import MenuDialog from '@/app/components/header/account-setting/menu-dialog'
|
||||
import { seedSystemFeatures } from '@/test/console/query-data'
|
||||
import { ContactsImPlatformRuntimeProvider } from '../composition'
|
||||
import { ContactsImPlatformManagementSurface } from '../management-surface'
|
||||
import { channelSummary } from './fixtures/channel-client'
|
||||
@@ -66,6 +67,7 @@ async function setup() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
seedSystemFeatures(queryClient, { deployment_edition: 'CLOUD' })
|
||||
await render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter hasMemory>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { skipToken, useIsMutating, useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
import { ContactImBindingContent } from './binding-dialog'
|
||||
import { useContactsImPlatformOrganization } from './composition-context'
|
||||
@@ -116,6 +117,11 @@ export function ContactsImPlatformManagementSurface() {
|
||||
const { t } = useTranslation(['contacts'])
|
||||
const { t: tCommon } = useTranslation(['common'])
|
||||
const organization = useContactsImPlatformOrganization()
|
||||
const { data: deploymentEdition } = useQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
enabled: organization.canManage,
|
||||
select: (data) => data.deployment_edition,
|
||||
})
|
||||
const api = consoleQuery.workspaces.current.humanInput.v2
|
||||
const integrationsQuery = useQuery(
|
||||
api.channels.get.queryOptions({ enabled: organization.canManage, context: { silent: true } }),
|
||||
@@ -143,14 +149,15 @@ export function ContactsImPlatformManagementSurface() {
|
||||
const [syncRunId, setSyncRunId] = useContactImSyncRunUrlState()
|
||||
const [, setSyncResult] = useContactImSyncResultUrlState()
|
||||
const integrations = integrationsQuery.data?.channels ?? []
|
||||
const providers = [
|
||||
...(providersQuery.data?.email_providers ?? []),
|
||||
...(providersQuery.data?.im_providers ?? []),
|
||||
].map(({ provider }) => ({
|
||||
provider,
|
||||
displayName: providerMetadata[provider].name,
|
||||
requiredFields: providerMetadata[provider].fields,
|
||||
}))
|
||||
const emailProviders =
|
||||
deploymentEdition === 'CLOUD' ? (providersQuery.data?.email_providers ?? []) : []
|
||||
const providers = [...emailProviders, ...(providersQuery.data?.im_providers ?? [])].map(
|
||||
({ provider }) => ({
|
||||
provider,
|
||||
displayName: providerMetadata[provider].name,
|
||||
requiredFields: providerMetadata[provider].fields,
|
||||
}),
|
||||
)
|
||||
const byProvider = new Map(integrations.map((integration) => [integration.provider, integration]))
|
||||
const configured = providers.filter((provider) => byProvider.has(provider.provider))
|
||||
const available = providers.filter((provider) => !byProvider.has(provider.provider))
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "اختر من يمكنه الرد على خطوة الإدخال البشري هذه.",
|
||||
"nodes.humanInputV2.recipients.initiator": "يمكن للمستخدم الذي يبدأ هذا التشغيل عبر Web App أو CLI الموافقة",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "يمكن للشخص الذي يبدأ تشغيل سير العمل هذا الرد.",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "عمليات التشغيل التي تبدأ عبر مُشغّل ليس لها مُبادر يتلقى طلبات الموافقة. اختر جهة اتصال أو استخدم متغيرًا لتحديد عنوان بريد إلكتروني.",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "اكتب أو الصق عدة عناوين بريد إلكتروني مفصولة بفواصل. اضغط على / لإدراج متغير.",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "إدراج متغيّر",
|
||||
"nodes.humanInputV2.recipients.loadError": "تعذّر تحميل جهات الاتصال. تم الاحتفاظ بمعرّفات جهات الاتصال المحفوظة.",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "Bu İnsan girişi addımına kimin cavab verə biləcəyini seçin.",
|
||||
"nodes.humanInputV2.recipients.initiator": "Bu icranı Web App və ya CLI vasitəsilə başladan istifadəçi təsdiqləyə bilər",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "Bu iş axınının icrasını başladan şəxs cavab verə bilər.",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Tətikləyici ilə başlayan icralarda təsdiq sorğularını qəbul edə biləcək bir başladan şəxs yoxdur. Kontakt seçin və ya e-poçt ünvanını dəyişən vasitəsilə göstərin.",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "Vergüllə ayrılmış bir neçə e-poçt ünvanı yazın və ya yapışdırın. Dəyişən əlavə etmək üçün / düyməsini basın.",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "Dəyişən əlavə et",
|
||||
"nodes.humanInputV2.recipients.loadError": "Kontaktları yükləmək mümkün olmadı. Saxlanılmış kontakt ID-ləri qorunur.",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "Wählen Sie aus, wer auf diesen Schritt für menschliche Eingaben antworten kann.",
|
||||
"nodes.humanInputV2.recipients.initiator": "Der Benutzer, der diesen Durchlauf über Web App oder CLI startet, kann genehmigen",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "Die Person, die diesen Workflow-Lauf startet, kann antworten.",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Bei einem durch einen Trigger gestarteten Lauf gibt es keinen Initiator, der Genehmigungsanfragen empfangen kann. Wählen Sie einen Kontakt aus oder geben Sie eine E-Mail-Adresse über eine Variable an.",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "Geben Sie mehrere durch Kommas getrennte E-Mail-Adressen ein oder fügen Sie sie ein. Drücken Sie /, um eine Variable einzufügen.",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "Variable einfügen",
|
||||
"nodes.humanInputV2.recipients.loadError": "Kontakte konnten nicht geladen werden. Gespeicherte Kontakt-IDs bleiben erhalten.",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "Choose who can respond to this Human Input step.",
|
||||
"nodes.humanInputV2.recipients.initiator": "The user who starts this run through Web App or CLI can approve",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "The person who starts this workflow run can respond.",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Trigger runs have no initiator to receive approval requests. Select a contact or use a variable to provide an email address.",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "Type or paste multiple emails separated by commas. Press / to insert a variable.",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "Insert variable",
|
||||
"nodes.humanInputV2.recipients.loadError": "Contacts could not be loaded. Stored contact IDs are preserved.",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "Elige quién puede responder a este paso de Entrada Humana.",
|
||||
"nodes.humanInputV2.recipients.initiator": "El usuario que inicia esta ejecución a través de Web App o CLI puede aprobar",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "La persona que inicia esta ejecución del flujo de trabajo puede responder.",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Las ejecuciones iniciadas por un activador no tienen un iniciador que pueda recibir solicitudes de aprobación. Selecciona un contacto o usa una variable para indicar una dirección de correo electrónico.",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "Escribe o pega varias direcciones de correo separadas por comas. Pulsa / para insertar una variable.",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "Insertar variable",
|
||||
"nodes.humanInputV2.recipients.loadError": "No se pudieron cargar los contactos. Se conservan los ID de contacto guardados.",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "انتخاب کنید چه کسی میتواند به این مرحله ورودی انسانی پاسخ دهد.",
|
||||
"nodes.humanInputV2.recipients.initiator": "کاربری که این اجرا را از طریق Web App یا CLI آغاز میکند، میتواند تأیید کند",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "فردی که این اجرای گردش کار را آغاز میکند میتواند پاسخ دهد.",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "در اجراهایی که با محرک شروع میشوند، آغازکنندهای برای دریافت درخواستهای تأیید وجود ندارد. یک مخاطب انتخاب کنید یا نشانی ایمیل را با یک متغیر مشخص کنید.",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "چند نشانی ایمیل را با جداکنندهٔ ویرگول تایپ یا جایگذاری کنید. برای درج متغیر، / را فشار دهید.",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "درج متغیر",
|
||||
"nodes.humanInputV2.recipients.loadError": "بارگذاری مخاطبان ممکن نبود. شناسههای ذخیرهشده مخاطبان حفظ شدهاند.",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "Choisissez qui peut répondre à cette étape de saisie humaine.",
|
||||
"nodes.humanInputV2.recipients.initiator": "L’utilisateur qui lance cette exécution via Web App ou CLI peut approuver",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "La personne qui lance cette exécution du workflow peut répondre.",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Lorsqu’une exécution démarre via un déclencheur, il n’y a pas d’initiateur pour recevoir les demandes d’approbation. Sélectionnez un contact ou indiquez une adresse e-mail à l’aide d’une variable.",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "Saisissez ou collez plusieurs adresses e-mail séparées par des virgules. Appuyez sur / pour insérer une variable.",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "Insérer une variable",
|
||||
"nodes.humanInputV2.recipients.loadError": "Impossible de charger les contacts. Les ID de contact enregistrés sont conservés.",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "चुनें कि मानव इनपुट के इस चरण का उत्तर कौन दे सकता है।",
|
||||
"nodes.humanInputV2.recipients.initiator": "Web App या CLI के ज़रिए यह रन शुरू करने वाला उपयोगकर्ता अनुमोदन कर सकता है",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "इस वर्कफ़्लो को शुरू करने वाला व्यक्ति उत्तर दे सकता है।",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "ट्रिगर से शुरू होने वाले रन में अनुमोदन अनुरोध पाने के लिए कोई आरंभकर्ता नहीं होता। कोई संपर्क चुनें या ईमेल पता देने के लिए किसी वेरिएबल का उपयोग करें।",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "अल्पविराम से अलग किए गए कई ईमेल टाइप या पेस्ट करें। वेरिएबल डालने के लिए / दबाएँ।",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "वेरिएबल डालें",
|
||||
"nodes.humanInputV2.recipients.loadError": "संपर्क लोड नहीं किए जा सके। सहेजी गई संपर्क ID सुरक्षित हैं।",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "Pilih siapa yang dapat merespons langkah Input Manusia ini.",
|
||||
"nodes.humanInputV2.recipients.initiator": "Pengguna yang memulai proses ini melalui Web App atau CLI dapat menyetujui",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "Orang yang memulai eksekusi alur kerja ini dapat merespons.",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Eksekusi yang dimulai oleh pemicu tidak memiliki inisiator yang dapat menerima permintaan persetujuan. Pilih kontak atau gunakan variabel untuk menentukan alamat email.",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "Ketik atau tempel beberapa alamat email yang dipisahkan dengan koma. Tekan / untuk menyisipkan variabel.",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "Sisipkan variabel",
|
||||
"nodes.humanInputV2.recipients.loadError": "Kontak tidak dapat dimuat. ID kontak yang tersimpan tetap dipertahankan.",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "Scegli chi può rispondere a questo passaggio di input umano.",
|
||||
"nodes.humanInputV2.recipients.initiator": "L’utente che avvia questa esecuzione tramite Web App o CLI può approvare",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "La persona che avvia questa esecuzione del flusso di lavoro può rispondere.",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Quando un trigger avvia l’esecuzione, nessun utente può ricevere le richieste di approvazione come iniziatore. Seleziona un contatto o indica un indirizzo email tramite una variabile.",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "Digita o incolla più indirizzi email separati da virgole. Premi / per inserire una variabile.",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "Inserisci variabile",
|
||||
"nodes.humanInputV2.recipients.loadError": "Impossibile caricare i contatti. Gli ID dei contatti salvati vengono mantenuti.",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "この人間の入力ステップに回答できる人を選択します。",
|
||||
"nodes.humanInputV2.recipients.initiator": "Web App または CLI から今回の実行を開始したユーザーが承認できます",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "このワークフローの実行を開始した人が回答できます。",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "トリガーから開始した実行には、承認リクエストを受け取る開始者がいません。連絡先を選択するか、変数でメールアドレスを指定してください。",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "複数のメールアドレスをカンマで区切って入力または貼り付けます。/ を押すと変数を挿入できます。",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "変数を挿入",
|
||||
"nodes.humanInputV2.recipients.loadError": "連絡先を読み込めませんでした。保存済みの連絡先 ID は保持されています。",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "이 사람 입력 단계에 응답할 수 있는 사람을 선택하세요.",
|
||||
"nodes.humanInputV2.recipients.initiator": "Web App 또는 CLI를 통해 이번 실행을 시작한 사용자가 승인할 수 있습니다",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "이 워크플로 실행을 시작한 사람이 응답할 수 있습니다.",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "트리거로 시작된 실행에는 승인 요청을 받을 시작자가 없습니다. 연락처를 선택하거나 변수로 이메일 주소를 지정하세요.",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "여러 이메일 주소를 쉼표로 구분하여 입력하거나 붙여넣으세요. /를 눌러 변수를 삽입하세요.",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "변수 삽입",
|
||||
"nodes.humanInputV2.recipients.loadError": "연락처를 불러올 수 없습니다. 저장된 연락처 ID는 유지됩니다.",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "ເລືອກຜູ້ທີ່ສາມາດຕອບຂັ້ນຕອນການປ້ອນຂໍ້ມູນໂດຍມະນຸດນີ້ໄດ້.",
|
||||
"nodes.humanInputV2.recipients.initiator": "ຜູ້ໃຊ້ທີ່ເລີ່ມການເຮັດວຽກຄັ້ງນີ້ຜ່ານ Web App ຫຼື CLI ສາມາດອະນຸມັດໄດ້",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "ຜູ້ທີ່ເລີ່ມການເຮັດວຽກຂອງ Workflow ຄັ້ງນີ້ສາມາດຕອບໄດ້.",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "ການເຮັດວຽກທີ່ເລີ່ມຈາກຕົວກະຕຸ້ນບໍ່ມີຜູ້ເລີ່ມທີ່ຈະຮັບຄຳຂໍອະນຸມັດ. ເລືອກຜູ້ຕິດຕໍ່ ຫຼື ໃຊ້ຕົວແປເພື່ອລະບຸທີ່ຢູ່ອີເມວ.",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "ພິມ ຫຼື ວາງຫຼາຍອີເມວໂດຍໃຊ້ , ຄັ່ນແຕ່ລະອີເມວ. ກົດ / ເພື່ອແຊກຕົວແປ.",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "ແຊກຕົວແປ",
|
||||
"nodes.humanInputV2.recipients.loadError": "ບໍ່ສາມາດໂຫຼດຜູ້ຕິດຕໍ່ໄດ້. ID ຜູ້ຕິດຕໍ່ທີ່ບັນທຶກໄວ້ຍັງຖືກຮັກສາໄວ້.",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "Kies wie op deze Human Input-stap kan reageren.",
|
||||
"nodes.humanInputV2.recipients.initiator": "De gebruiker die deze uitvoering via Web App of CLI start, kan goedkeuren",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "De persoon die deze workflowuitvoering start, kan reageren.",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Bij uitvoeringen die door een trigger worden gestart, is er geen initiator die goedkeuringsverzoeken kan ontvangen. Selecteer een contact of geef een e-mailadres op via een variabele.",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "Typ of plak meerdere e-mailadressen, gescheiden door komma’s. Druk op / om een variabele in te voegen.",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "Variabele invoegen",
|
||||
"nodes.humanInputV2.recipients.loadError": "Contacten konden niet worden geladen. Opgeslagen contact-ID’s blijven behouden.",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "Wybierz, kto może odpowiedzieć w tym kroku danych wprowadzanych przez człowieka.",
|
||||
"nodes.humanInputV2.recipients.initiator": "Użytkownik, który uruchomi to wykonanie przez Web App lub CLI, może je zatwierdzić",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "Osoba rozpoczynająca to uruchomienie przepływu pracy może odpowiedzieć.",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Uruchomienia przez wyzwalacz nie mają inicjatora, który mógłby otrzymywać prośby o zatwierdzenie. Wybierz kontakt lub podaj adres e-mail za pomocą zmiennej.",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "Wpisz lub wklej wiele adresów e-mail oddzielonych przecinkami. Naciśnij /, aby wstawić zmienną.",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "Wstaw zmienną",
|
||||
"nodes.humanInputV2.recipients.loadError": "Nie udało się wczytać kontaktów. Zapisane identyfikatory kontaktów zostały zachowane.",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "Escolha quem pode responder a esta etapa de Entrada Humana.",
|
||||
"nodes.humanInputV2.recipients.initiator": "O usuário que inicia esta execução pelo Web App ou CLI pode aprovar",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "A pessoa que inicia esta execução do fluxo de trabalho pode responder.",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "As execuções iniciadas por um gatilho não têm um iniciador que possa receber solicitações de aprovação. Selecione um contato ou use uma variável para informar um endereço de e-mail.",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "Digite ou cole vários endereços de e-mail separados por vírgulas. Pressione / para inserir uma variável.",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "Inserir variável",
|
||||
"nodes.humanInputV2.recipients.loadError": "Não foi possível carregar os contatos. Os IDs de contato armazenados foram preservados.",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "Alege cine poate răspunde la acest pas de input uman.",
|
||||
"nodes.humanInputV2.recipients.initiator": "Utilizatorul care pornește această execuție prin Web App sau CLI poate aproba",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "Persoana care pornește această rulare a fluxului de lucru poate răspunde.",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Execuțiile pornite de un declanșator nu au un inițiator care să primească cereri de aprobare. Selectează un contact sau specifică o adresă de e-mail folosind o variabilă.",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "Introdu sau lipește mai multe adrese de e-mail separate prin virgule. Apasă / pentru a insera o variabilă.",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "Inserează o variabilă",
|
||||
"nodes.humanInputV2.recipients.loadError": "Contactele nu au putut fi încărcate. ID-urile de contact salvate sunt păstrate.",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "Выберите, кто может ответить на этом шаге ввода человека.",
|
||||
"nodes.humanInputV2.recipients.initiator": "Пользователь, запустивший это выполнение через Web App или CLI, может его одобрить",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "Ответить может пользователь, запустивший этот рабочий процесс.",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "При запуске по триггеру нет инициатора, который мог бы получить запрос на согласование. Выберите контакт или укажите адрес электронной почты с помощью переменной.",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "Введите или вставьте несколько адресов электронной почты через запятую. Нажмите /, чтобы вставить переменную.",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "Вставить переменную",
|
||||
"nodes.humanInputV2.recipients.loadError": "Не удалось загрузить контакты. Сохранённые ID контактов не изменены.",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "Izberite, kdo lahko odgovori v tem koraku človeškega vnosa.",
|
||||
"nodes.humanInputV2.recipients.initiator": "Uporabnik, ki zažene to izvajanje prek Web App ali CLI, lahko odobri",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "Odgovori lahko oseba, ki zažene ta delovni tok.",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Izvajanja, ki jih zažene sprožilec, nimajo pobudnika, ki bi lahko prejel zahteve za odobritev. Izberite stik ali določite e-poštni naslov s spremenljivko.",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "Vnesite ali prilepite več e-poštnih naslovov, ločenih z vejicami. Pritisnite /, da vstavite spremenljivko.",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "Vstavi spremenljivko",
|
||||
"nodes.humanInputV2.recipients.loadError": "Stikov ni bilo mogoče naložiti. Shranjeni ID-ji stikov so ohranjeni.",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "เลือกผู้ที่สามารถตอบในขั้นตอนข้อมูลจากมนุษย์นี้",
|
||||
"nodes.humanInputV2.recipients.initiator": "ผู้ใช้ที่เริ่มการทำงานครั้งนี้ผ่าน Web App หรือ CLI สามารถอนุมัติได้",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "ผู้ที่เริ่มการเรียกใช้เวิร์กโฟลว์นี้สามารถตอบได้",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "การทำงานที่เริ่มด้วยทริกเกอร์ไม่มีผู้เริ่มที่สามารถรับคำขออนุมัติได้ เลือกผู้ติดต่อหรือใช้ตัวแปรเพื่อระบุที่อยู่อีเมล",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "พิมพ์หรือวางหลายอีเมลโดยคั่นด้วยเครื่องหมายจุลภาค กด / เพื่อแทรกตัวแปร",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "แทรกตัวแปร",
|
||||
"nodes.humanInputV2.recipients.loadError": "ไม่สามารถโหลดผู้ติดต่อได้ ID ผู้ติดต่อที่บันทึกไว้ยังคงอยู่",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "Bu İnsan Girdisi adımına kimlerin yanıt verebileceğini seçin.",
|
||||
"nodes.humanInputV2.recipients.initiator": "Bu çalıştırmayı Web App veya CLI üzerinden başlatan kullanıcı onaylayabilir",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "Bu iş akışını başlatan kişi yanıt verebilir.",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Tetikleyiciyle başlatılan çalıştırmalarda onay isteklerini alabilecek bir başlatan yoktur. Bir kişi seçin veya e-posta adresini bir değişkenle belirtin.",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "Virgülle ayrılmış birden fazla e-posta adresi yazın veya yapıştırın. Değişken eklemek için / tuşuna basın.",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "Değişken ekle",
|
||||
"nodes.humanInputV2.recipients.loadError": "Kişiler yüklenemedi. Saklanan kişi kimlikleri korunuyor.",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "Виберіть, хто може відповісти на цьому кроці введення людини.",
|
||||
"nodes.humanInputV2.recipients.initiator": "Користувач, який запускає це виконання через Web App або CLI, може його схвалити",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "Відповісти може особа, яка запускає цей робочий процес.",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Запуски за тригером не мають ініціатора, який міг би отримати запит на схвалення. Виберіть контакт або вкажіть адресу електронної пошти за допомогою змінної.",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "Введіть або вставте кілька електронних адрес, розділених комами. Натисніть /, щоб вставити змінну.",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "Вставити змінну",
|
||||
"nodes.humanInputV2.recipients.loadError": "Не вдалося завантажити контакти. Збережені ID контактів не змінено.",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "Chọn người có thể phản hồi ở bước Đầu vào của con người này.",
|
||||
"nodes.humanInputV2.recipients.initiator": "Người dùng bắt đầu lần chạy này qua Web App hoặc CLI có thể phê duyệt",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "Người bắt đầu lần chạy quy trình này có thể phản hồi.",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Lần chạy do trình kích hoạt khởi động không có người khởi tạo để nhận yêu cầu phê duyệt. Hãy chọn một liên hệ hoặc dùng biến để cung cấp địa chỉ email.",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "Nhập hoặc dán nhiều email, phân tách bằng dấu phẩy. Nhấn / để chèn biến.",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "Chèn biến",
|
||||
"nodes.humanInputV2.recipients.loadError": "Không thể tải các liên hệ. ID liên hệ đã lưu vẫn được giữ lại.",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "选择可以响应此人工介入步骤的人员。",
|
||||
"nodes.humanInputV2.recipients.initiator": "通过 Web App 或 CLI 发起本次运行的用户可审批",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "发起本次工作流运行的用户可以响应。",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "触发器运行没有可接收审批的发起人,请选择联系人或使用变量提供邮箱。",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "输入或粘贴多个邮箱,以逗号分隔。按 / 插入变量。",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "插入变量",
|
||||
"nodes.humanInputV2.recipients.loadError": "无法加载联系人。已保存的联系人 ID 会继续保留。",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"nodes.humanInputV2.recipients.help": "選擇可以回應此人工輸入步驟的人員。",
|
||||
"nodes.humanInputV2.recipients.initiator": "透過 Web App 或 CLI 發起本次執行的使用者可審批",
|
||||
"nodes.humanInputV2.recipients.initiatorDescription": "啟動此次工作流程執行的人員可以回應。",
|
||||
"nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "透過觸發器啟動的執行沒有可接收審批請求的發起人。請選取聯絡人,或使用變數提供電子郵件地址。",
|
||||
"nodes.humanInputV2.recipients.inputHelp": "輸入或貼上多個電子郵件地址,以逗號分隔。按 / 插入變數。",
|
||||
"nodes.humanInputV2.recipients.insertVariable": "插入變數",
|
||||
"nodes.humanInputV2.recipients.loadError": "無法載入聯絡人。已保留儲存的聯絡人 ID。",
|
||||
|
||||
Vendored
+1
@@ -6146,6 +6146,7 @@ export default interface Resources {
|
||||
'nodes.humanInputV2.recipients.help': 'Choose who can respond to this Human Input step.'
|
||||
'nodes.humanInputV2.recipients.initiator': 'The user who starts this run through Web App or CLI can approve'
|
||||
'nodes.humanInputV2.recipients.initiatorDescription': 'The person who starts this workflow run can respond.'
|
||||
'nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow': 'Trigger runs have no initiator to receive approval requests. Select a contact or use a variable to provide an email address.'
|
||||
'nodes.humanInputV2.recipients.inputHelp': 'Type or paste multiple emails separated by commas. Press / to insert a variable.'
|
||||
'nodes.humanInputV2.recipients.insertVariable': 'Insert variable'
|
||||
'nodes.humanInputV2.recipients.loadError': 'Contacts could not be loaded. Stored contact IDs are preserved.'
|
||||
|
||||
Reference in new issue
Block a user