Return the form expired error code in get form definition in WEbAPp Api (vibe-kanban 758765b0)

This commit is contained in:
QuantumGhost
2026-01-26 16:14:02 +08:00
parent 6531267ec3
commit b59713b980
5 changed files with 66 additions and 10 deletions

View File

@ -76,7 +76,7 @@ class HumanInputFormApi(Resource):
if form is None:
raise NotFoundError("Form not found")
service._ensure_not_submitted(form)
service.ensure_form_active(form)
app_model, site = _get_app_site_from_form(form)
return _jsonify_form_definition(form, site_payload=serialize_app_site_payload(app_model, site, None))

View File

@ -143,6 +143,7 @@ class HumanInputFormRecord:
form_kind: HumanInputFormKind
definition: FormDefinition
rendered_content: str
created_at: datetime
expiration_time: datetime
status: HumanInputFormStatus
selected_action_id: str | None
@ -172,6 +173,7 @@ class HumanInputFormRecord:
form_kind=form_model.form_kind,
definition=FormDefinition.model_validate_json(form_model.form_definition),
rendered_content=form_model.rendered_content,
created_at=form_model.created_at,
expiration_time=form_model.expiration_time,
status=form_model.status,
selected_action_id=form_model.selected_action_id,

View File

@ -1,10 +1,12 @@
import logging
from collections.abc import Mapping
from datetime import datetime, timedelta
from typing import Any
from sqlalchemy import Engine, select
from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from core.repositories.human_input_reposotiry import (
HumanInputFormRecord,
HumanInputFormSubmissionRepository,
@ -15,7 +17,7 @@ from core.workflow.nodes.human_input.entities import (
validate_human_input_submission,
)
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
from libs.datetime_utils import naive_utc_now
from libs.datetime_utils import ensure_naive_utc, naive_utc_now
from libs.exception import BaseHTTPException
from models.human_input import RecipientType
from models.model import App, AppMode
@ -68,7 +70,11 @@ class Form:
return self._record.form_kind
@property
def expiration_time(self):
def created_at(self) -> "datetime":
return self._record.created_at
@property
def expiration_time(self) -> "datetime":
return self._record.expiration_time
@ -159,7 +165,7 @@ class HumanInputService:
if form is None or form.recipient_type != recipient_type:
raise WebAppDeliveryNotEnabledError()
self._ensure_form_active(form)
self.ensure_form_active(form)
self._validate_submission(form=form, selected_action_id=selected_action_id, form_data=form_data)
result = self._form_repository.mark_submitted(
@ -177,13 +183,15 @@ class HumanInputService:
return
self._enqueue_resume(result.workflow_run_id)
def _ensure_form_active(self, form: Form) -> None:
def ensure_form_active(self, form: Form) -> None:
if form.submitted:
raise FormSubmittedError(form.id)
if form.status == HumanInputFormStatus.TIMEOUT:
raise FormExpiredError(form.id)
now = naive_utc_now()
if form.expiration_time <= now:
if ensure_naive_utc(form.expiration_time) <= now:
raise FormExpiredError(form.id)
if self._is_globally_expired(form, now=now):
raise FormExpiredError(form.id)
def _ensure_not_submitted(self, form: Form) -> None:
@ -229,3 +237,14 @@ class HumanInputService:
return
logger.warning("App mode %s does not support resume for workflow run %s", app.mode, workflow_run_id)
def _is_globally_expired(self, form: Form, *, now: datetime | None = None) -> bool:
global_timeout_seconds = dify_config.HITL_GLOBAL_TIMEOUT_SECONDS
if global_timeout_seconds <= 0:
return False
if form.workflow_run_id is None:
return False
current = now or naive_utc_now()
created_at = ensure_naive_utc(form.created_at)
global_deadline = created_at + timedelta(seconds=global_timeout_seconds)
return global_deadline <= current

View File

@ -15,6 +15,7 @@ from werkzeug.exceptions import Forbidden
import controllers.web.human_input_form as human_input_module
import controllers.web.site as site_module
from models.human_input import RecipientType
from services.human_input_service import FormExpiredError
HumanInputFormApi = human_input_module.HumanInputFormApi
TenantStatus = human_input_module.TenantStatus
@ -59,7 +60,7 @@ class _FakeDB:
def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
"""GET returns form definition merged with site payload."""
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
expiration_time = datetime(2099, 1, 1, tzinfo=UTC)
class _FakeDefinition:
def model_dump(self):
@ -176,7 +177,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: Flask):
"""GET returns form payload for backstage token."""
expiration_time = datetime(2024, 1, 2, tzinfo=UTC)
expiration_time = datetime(2099, 1, 2, tzinfo=UTC)
class _FakeDefinition:
def model_dump(self):
@ -289,7 +290,7 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F
def test_get_form_raises_forbidden_when_site_missing(monkeypatch: pytest.MonkeyPatch, app: Flask):
"""GET raises Forbidden if site cannot be resolved."""
expiration_time = datetime(2024, 1, 3, tzinfo=UTC)
expiration_time = datetime(2099, 1, 3, tzinfo=UTC)
class _FakeDefinition:
def model_dump(self):
@ -356,3 +357,21 @@ def test_submit_form_accepts_backstage_token(monkeypatch: pytest.MonkeyPatch, ap
form_data={"content": "ok"},
submission_end_user_id=None,
)
def test_get_form_raises_expired(monkeypatch: pytest.MonkeyPatch, app: Flask):
class _FakeForm:
pass
form = _FakeForm()
service_mock = MagicMock()
service_mock.get_form_by_token.return_value = form
service_mock.ensure_form_active.side_effect = FormExpiredError("form-id")
monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock)
monkeypatch.setattr(human_input_module, "db", _FakeDB(_FakeSession({})))
with app.test_request_context("/api/form/human_input/token-1", method="GET"):
with pytest.raises(FormExpiredError):
HumanInputFormApi().get("token-1")
service_mock.ensure_form_active.assert_called_once_with(form)

View File

@ -4,6 +4,7 @@ from unittest.mock import MagicMock
import pytest
import services.human_input_service as human_input_service_module
from core.repositories.human_input_reposotiry import (
HumanInputFormRecord,
HumanInputFormSubmissionRepository,
@ -20,7 +21,7 @@ from core.workflow.nodes.human_input.enums import (
TimeoutUnit,
)
from models.human_input import RecipientType
from services.human_input_service import HumanInputService, InvalidFormDataError
from services.human_input_service import Form, FormExpiredError, HumanInputService, InvalidFormDataError
@pytest.fixture
@ -53,6 +54,7 @@ def sample_form_record():
timeout_unit=TimeoutUnit.HOUR,
),
rendered_content="<p>hello</p>",
created_at=datetime.utcnow(),
expiration_time=datetime.utcnow() + timedelta(hours=1),
status=HumanInputFormStatus.WAITING,
selected_action_id=None,
@ -95,6 +97,20 @@ def test_enqueue_resume_dispatches_task_for_workflow(mocker, mock_session_factor
assert call_kwargs["kwargs"]["payload"]["workflow_run_id"] == "workflow-run-id"
def test_ensure_form_active_respects_global_timeout(monkeypatch, sample_form_record, mock_session_factory):
session_factory, _ = mock_session_factory
service = HumanInputService(session_factory)
expired_record = dataclasses.replace(
sample_form_record,
created_at=datetime.utcnow() - timedelta(hours=2),
expiration_time=datetime.utcnow() + timedelta(hours=2),
)
monkeypatch.setattr(human_input_service_module.dify_config, "HITL_GLOBAL_TIMEOUT_SECONDS", 3600)
with pytest.raises(FormExpiredError):
service.ensure_form_active(Form(expired_record))
def test_enqueue_resume_dispatches_task_for_advanced_chat(mocker, mock_session_factory):
session_factory, session = mock_session_factory
service = HumanInputService(session_factory)