mirror of
https://github.com/langgenius/dify.git
synced 2026-06-25 16:47:17 +08:00
Compare commits
5 Commits
deploy/saa
...
refactor/m
| Author | SHA1 | Date | |
|---|---|---|---|
| ce5a809c71 | |||
| b33e8f0ddb | |||
| 8f74e176ca | |||
| b9bcf31c72 | |||
| abf2986299 |
@ -5,7 +5,7 @@ requires-python = "~=3.12.0"
|
||||
|
||||
dependencies = [
|
||||
# Legacy: mature and widely deployed
|
||||
"bleach>=6.3.0,<7.0.0",
|
||||
"bleach>=6.4.0,<7.0.0",
|
||||
"boto3>=1.43.24,<2.0.0",
|
||||
"celery>=5.6.3,<6.0.0",
|
||||
"croniter>=6.2.2,<7.0.0",
|
||||
|
||||
@ -330,8 +330,6 @@ _LEGACY_WORKSPACE_OWNER_KEYS: list[str] = [
|
||||
"snippets.management",
|
||||
"tool.manage",
|
||||
"mcp.manage",
|
||||
"snippets.create_and_modify",
|
||||
"snippets.management",
|
||||
]
|
||||
|
||||
_LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
|
||||
@ -361,8 +359,6 @@ _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
|
||||
"snippets.management",
|
||||
"tool.manage",
|
||||
"mcp.manage",
|
||||
"snippets.create_and_modify",
|
||||
"snippets.management",
|
||||
]
|
||||
|
||||
_LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
|
||||
@ -378,7 +374,6 @@ _LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
|
||||
"dataset.external.connect",
|
||||
"snippets.create_and_modify",
|
||||
"tool.manage",
|
||||
"snippets.create_and_modify",
|
||||
"billing.view",
|
||||
"billing.subscription.manage",
|
||||
"billing.manage",
|
||||
|
||||
@ -19,11 +19,16 @@ from core.app.entities.app_invoke_entities import (
|
||||
InvokeFrom,
|
||||
WorkflowAppGenerateEntity,
|
||||
)
|
||||
from core.app.entities.task_entities import WorkflowFinishStreamResponse, WorkflowStartStreamResponse
|
||||
from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig, WorkflowResumptionContext
|
||||
from core.repositories import DifyCoreRepositoryFactory
|
||||
from extensions.ext_database import db
|
||||
from graphon.entities import WorkflowStartReason
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from graphon.runtime import GraphRuntimeState
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.flask_utils import set_login_user
|
||||
from libs.helper import to_timestamp
|
||||
from models.account import Account
|
||||
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
|
||||
from models.model import App, AppMode, Conversation, EndUser, Message
|
||||
@ -173,14 +178,24 @@ class _AppRunner:
|
||||
)
|
||||
except Exception as exc:
|
||||
if exec_params.streaming:
|
||||
_publish_error_event(exc, exec_params.workflow_run_id, exec_params.app_mode)
|
||||
_publish_failed_workflow_terminal_events(
|
||||
exc=exc,
|
||||
exec_params=exec_params,
|
||||
)
|
||||
raise
|
||||
|
||||
if not exec_params.streaming:
|
||||
return response
|
||||
|
||||
assert isinstance(response, Generator)
|
||||
_publish_streaming_response(response, exec_params.workflow_run_id, exec_params.app_mode)
|
||||
_publish_streaming_response(
|
||||
response,
|
||||
exec_params.workflow_run_id,
|
||||
exec_params.app_mode,
|
||||
exec_params.workflow_id,
|
||||
exec_params.args.get("inputs", {}),
|
||||
WorkflowStartReason.INITIAL,
|
||||
)
|
||||
|
||||
def _run_app(
|
||||
self,
|
||||
@ -246,29 +261,197 @@ def _resolve_user_for_run(session: Session, workflow_run: WorkflowRun) -> Accoun
|
||||
return session.get(EndUser, workflow_run.created_by)
|
||||
|
||||
|
||||
def _publish_error_event(exc: Exception, workflow_run_id: str, app_mode: AppMode) -> None:
|
||||
topic = MessageBasedAppGenerator.get_response_topic(app_mode, workflow_run_id)
|
||||
payload = json.dumps({"event": "error", "message": str(exc), "status": 500})
|
||||
topic.publish(payload.encode())
|
||||
def _publish_failed_workflow_terminal_events(exc: Exception, exec_params: AppExecutionParams) -> None:
|
||||
"""Publish synthetic workflow lifecycle events for pre-runtime failures.
|
||||
|
||||
Early failures can happen before the app generator creates a task entity or
|
||||
emits any workflow queue events. In that window SSE consumers still need a
|
||||
normal terminal event to close their state machines, so we synthesize a
|
||||
minimal `workflow_started -> workflow_finished(failed)` sequence here.
|
||||
|
||||
`workflow_run_id` is reused as a synthetic `task_id` because no application
|
||||
task id exists yet on this failure path.
|
||||
"""
|
||||
timestamp = to_timestamp(naive_utc_now())
|
||||
assert timestamp is not None
|
||||
|
||||
topic = MessageBasedAppGenerator.get_response_topic(exec_params.app_mode, exec_params.workflow_run_id)
|
||||
started_payload = WorkflowStartStreamResponse(
|
||||
task_id=exec_params.workflow_run_id,
|
||||
workflow_run_id=exec_params.workflow_run_id,
|
||||
data=WorkflowStartStreamResponse.Data(
|
||||
id=exec_params.workflow_run_id,
|
||||
workflow_id=exec_params.workflow_id,
|
||||
inputs=exec_params.args.get("inputs", {}),
|
||||
created_at=timestamp,
|
||||
reason=WorkflowStartReason.INITIAL,
|
||||
),
|
||||
)
|
||||
topic.publish(json.dumps(started_payload.model_dump(mode="json"), ensure_ascii=False).encode())
|
||||
|
||||
finished_payload = WorkflowFinishStreamResponse(
|
||||
task_id=exec_params.workflow_run_id,
|
||||
workflow_run_id=exec_params.workflow_run_id,
|
||||
data=WorkflowFinishStreamResponse.Data(
|
||||
id=exec_params.workflow_run_id,
|
||||
workflow_id=exec_params.workflow_id,
|
||||
status=WorkflowExecutionStatus.FAILED,
|
||||
outputs=None,
|
||||
error=str(exc),
|
||||
elapsed_time=0.0,
|
||||
total_tokens=0,
|
||||
total_steps=0,
|
||||
created_by={},
|
||||
created_at=timestamp,
|
||||
finished_at=timestamp,
|
||||
exceptions_count=1,
|
||||
files=[],
|
||||
),
|
||||
)
|
||||
topic.publish(json.dumps(finished_payload.model_dump(mode="json"), ensure_ascii=False).encode())
|
||||
|
||||
|
||||
def _get_event_name(event: str | Mapping[str, Any] | BaseModel) -> str | None:
|
||||
if isinstance(event, BaseModel):
|
||||
# Temporary compatibility for legacy BaseModel stream events; remove after confirming generators always emit
|
||||
# str / Mapping responses.
|
||||
event_name = getattr(event, "event", None)
|
||||
elif isinstance(event, Mapping):
|
||||
event_name = event.get("event")
|
||||
else:
|
||||
return None
|
||||
|
||||
if event_name is None:
|
||||
return None
|
||||
return str(event_name)
|
||||
|
||||
|
||||
def _get_task_id(event: str | Mapping[str, Any] | BaseModel) -> str | None:
|
||||
if isinstance(event, BaseModel):
|
||||
# Temporary compatibility for legacy BaseModel stream events; remove after confirming generators always emit
|
||||
# str / Mapping responses.
|
||||
task_id = getattr(event, "task_id", None)
|
||||
elif isinstance(event, Mapping):
|
||||
task_id = event.get("task_id")
|
||||
else:
|
||||
return None
|
||||
|
||||
return task_id if isinstance(task_id, str) and task_id else None
|
||||
|
||||
|
||||
def _publish_streaming_response(
|
||||
response_stream: Generator[str | Mapping[str, Any] | BaseModel, None, None],
|
||||
workflow_run_id: str,
|
||||
workflow_run_id: str | uuid.UUID,
|
||||
app_mode: AppMode,
|
||||
workflow_id: str,
|
||||
inputs: Mapping[str, Any],
|
||||
started_reason: WorkflowStartReason,
|
||||
) -> None:
|
||||
topic = MessageBasedAppGenerator.get_response_topic(app_mode, workflow_run_id)
|
||||
for event in response_stream:
|
||||
try:
|
||||
if isinstance(event, BaseModel):
|
||||
payload = json.dumps(event.model_dump(mode="json"), ensure_ascii=False)
|
||||
else:
|
||||
payload = json.dumps(event, ensure_ascii=False, default=str)
|
||||
except (TypeError, ValueError):
|
||||
logger.exception("error while encoding event")
|
||||
continue
|
||||
"""Publish workflow stream events and close broken streams with a failed terminal event.
|
||||
|
||||
topic.publish(payload.encode())
|
||||
`_AppRunner.run()` only handles failures before the generator is returned.
|
||||
Once we start iterating the runtime stream, this helper becomes the last
|
||||
place that can guarantee SSE consumers eventually see a terminal workflow
|
||||
lifecycle event.
|
||||
"""
|
||||
normalized_workflow_run_id = str(workflow_run_id)
|
||||
|
||||
def _publish_failed_terminal_event(error_message: str, task_id: str, publish_started: bool) -> None:
|
||||
timestamp = to_timestamp(naive_utc_now())
|
||||
assert timestamp is not None
|
||||
|
||||
if publish_started:
|
||||
started_payload = WorkflowStartStreamResponse(
|
||||
task_id=task_id,
|
||||
workflow_run_id=normalized_workflow_run_id,
|
||||
data=WorkflowStartStreamResponse.Data(
|
||||
id=normalized_workflow_run_id,
|
||||
workflow_id=workflow_id,
|
||||
inputs=inputs,
|
||||
created_at=timestamp,
|
||||
reason=started_reason,
|
||||
),
|
||||
)
|
||||
topic.publish(
|
||||
json.dumps(
|
||||
started_payload.model_dump(mode="json", fallback=str),
|
||||
ensure_ascii=False,
|
||||
).encode()
|
||||
)
|
||||
|
||||
finished_payload = WorkflowFinishStreamResponse(
|
||||
task_id=task_id,
|
||||
workflow_run_id=normalized_workflow_run_id,
|
||||
data=WorkflowFinishStreamResponse.Data(
|
||||
id=normalized_workflow_run_id,
|
||||
workflow_id=workflow_id,
|
||||
status=WorkflowExecutionStatus.FAILED,
|
||||
outputs=None,
|
||||
error=error_message,
|
||||
elapsed_time=0.0,
|
||||
total_tokens=0,
|
||||
total_steps=0,
|
||||
created_by={},
|
||||
created_at=timestamp,
|
||||
finished_at=timestamp,
|
||||
exceptions_count=1,
|
||||
files=[],
|
||||
),
|
||||
)
|
||||
topic.publish(json.dumps(finished_payload.model_dump(mode="json"), ensure_ascii=False).encode())
|
||||
|
||||
terminal_events = {"workflow_finished", "workflow_paused"}
|
||||
unexpected_stream_end_message = "Workflow stream ended without a terminal event"
|
||||
topic = MessageBasedAppGenerator.get_response_topic(app_mode, normalized_workflow_run_id)
|
||||
started_published = False
|
||||
terminal_published = False
|
||||
last_task_id = normalized_workflow_run_id
|
||||
|
||||
try:
|
||||
for event in response_stream:
|
||||
event_name = _get_event_name(event)
|
||||
task_id = _get_task_id(event)
|
||||
if task_id is not None:
|
||||
last_task_id = task_id
|
||||
|
||||
try:
|
||||
if isinstance(event, BaseModel):
|
||||
payload = json.dumps(event.model_dump(mode="json"), ensure_ascii=False)
|
||||
else:
|
||||
payload = json.dumps(event, ensure_ascii=False, default=str)
|
||||
except (TypeError, ValueError):
|
||||
logger.exception("error while encoding event")
|
||||
continue
|
||||
|
||||
topic.publish(payload.encode())
|
||||
|
||||
if event_name == "workflow_started":
|
||||
started_published = True
|
||||
elif event_name in terminal_events:
|
||||
terminal_published = True
|
||||
except Exception as exc:
|
||||
if not terminal_published:
|
||||
logger.exception(
|
||||
"Workflow stream for run %s failed before terminal event; publishing fallback terminal event",
|
||||
normalized_workflow_run_id,
|
||||
)
|
||||
_publish_failed_terminal_event(
|
||||
error_message=str(exc) or exc.__class__.__name__,
|
||||
task_id=last_task_id,
|
||||
publish_started=not started_published,
|
||||
)
|
||||
raise
|
||||
|
||||
if not terminal_published:
|
||||
logger.warning(
|
||||
"Workflow stream for run %s ended without a terminal event; publishing fallback terminal event",
|
||||
normalized_workflow_run_id,
|
||||
)
|
||||
_publish_failed_terminal_event(
|
||||
error_message=unexpected_stream_end_message,
|
||||
task_id=last_task_id,
|
||||
publish_started=not started_published,
|
||||
)
|
||||
|
||||
|
||||
@shared_task(queue=WORKFLOW_BASED_APP_EXECUTION_QUEUE)
|
||||
@ -454,7 +637,14 @@ def _resume_advanced_chat(
|
||||
raise
|
||||
|
||||
assert isinstance(response, Generator)
|
||||
_publish_streaming_response(response, workflow_run_id, AppMode.ADVANCED_CHAT)
|
||||
_publish_streaming_response(
|
||||
response,
|
||||
workflow_run_id,
|
||||
AppMode.ADVANCED_CHAT,
|
||||
workflow.id,
|
||||
generate_entity.inputs,
|
||||
WorkflowStartReason.RESUMPTION,
|
||||
)
|
||||
|
||||
|
||||
def _resume_workflow(
|
||||
@ -509,7 +699,14 @@ def _resume_workflow(
|
||||
raise
|
||||
|
||||
assert isinstance(response, Generator)
|
||||
_publish_streaming_response(response, workflow_run_id, AppMode.WORKFLOW)
|
||||
_publish_streaming_response(
|
||||
response,
|
||||
workflow_run_id,
|
||||
AppMode.WORKFLOW,
|
||||
workflow.id,
|
||||
generate_entity.inputs,
|
||||
WorkflowStartReason.RESUMPTION,
|
||||
)
|
||||
|
||||
try:
|
||||
workflow_run_repo.delete_workflow_pause(pause_entity)
|
||||
|
||||
@ -621,6 +621,7 @@ class TestMyPermissions:
|
||||
|
||||
mock_send.assert_not_called()
|
||||
assert out.workspace.permission_keys == workspace_keys
|
||||
assert len(out.workspace.permission_keys) == len(set(out.workspace.permission_keys))
|
||||
assert out.app.default_permission_keys == app_keys
|
||||
assert out.dataset.default_permission_keys == dataset_keys
|
||||
assert out.app.overrides == []
|
||||
|
||||
@ -1,18 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom, WorkflowAppGenerateEntity
|
||||
from graphon.entities import WorkflowStartReason
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import App, AppMode, Conversation
|
||||
from models.workflow import Workflow, WorkflowRun
|
||||
from repositories.sqlalchemy_api_workflow_run_repository import _WorkflowRunError
|
||||
from tasks.app_generate import workflow_execute_task as workflow_execute_task_module
|
||||
from tasks.app_generate.workflow_execute_task import (
|
||||
AppExecutionParams,
|
||||
_AppRunner,
|
||||
_publish_streaming_response,
|
||||
_resume_advanced_chat,
|
||||
_resume_app_execution,
|
||||
@ -31,6 +39,11 @@ class _FakeSessionContext:
|
||||
return False
|
||||
|
||||
|
||||
class _StreamEventModel(BaseModel):
|
||||
event: object | None = None
|
||||
task_id: object | None = None
|
||||
|
||||
|
||||
def _build_advanced_chat_generate_entity(conversation_id: str | None) -> AdvancedChatAppGenerateEntity:
|
||||
return AdvancedChatAppGenerateEntity(
|
||||
task_id="task-id",
|
||||
@ -60,6 +73,46 @@ def _single_event_generator(payload):
|
||||
yield payload
|
||||
|
||||
|
||||
def _decode_published_payload(payload: bytes) -> dict[str, object] | str:
|
||||
return json.loads(payload.decode())
|
||||
|
||||
|
||||
def _published_payloads(topic: MagicMock) -> list[dict[str, object] | str]:
|
||||
return [_decode_published_payload(call.args[0]) for call in topic.publish.call_args_list]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("event", "expected"),
|
||||
[
|
||||
({"event": "workflow_started"}, "workflow_started"),
|
||||
({"event": 123}, "123"),
|
||||
(_StreamEventModel(event="workflow_started"), "workflow_started"),
|
||||
(_StreamEventModel(event=123), "123"),
|
||||
({}, None),
|
||||
(_StreamEventModel(), None),
|
||||
("workflow_started", None),
|
||||
],
|
||||
)
|
||||
def test_get_event_name(event: object, expected: str | None):
|
||||
assert workflow_execute_task_module._get_event_name(event) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("event", "expected"),
|
||||
[
|
||||
({"task_id": "task-id"}, "task-id"),
|
||||
(_StreamEventModel(task_id="task-id"), "task-id"),
|
||||
({"task_id": 123}, None),
|
||||
(_StreamEventModel(task_id=123), None),
|
||||
({"task_id": ""}, None),
|
||||
(_StreamEventModel(), None),
|
||||
("task-id", None),
|
||||
],
|
||||
)
|
||||
def test_get_task_id(event: object, expected: str | None):
|
||||
assert workflow_execute_task_module._get_task_id(event) == expected
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_topic(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
|
||||
topic = MagicMock()
|
||||
@ -72,21 +125,413 @@ def mock_topic(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
|
||||
|
||||
def test_publish_streaming_response_with_uuid(mock_topic: MagicMock):
|
||||
workflow_run_id = uuid.uuid4()
|
||||
response_stream = iter([{"event": "foo"}, "ping"])
|
||||
response_stream = iter(
|
||||
[
|
||||
{"event": "workflow_started", "task_id": "task-id"},
|
||||
{"event": "workflow_finished", "task_id": "task-id", "data": {"status": "succeeded"}},
|
||||
]
|
||||
)
|
||||
|
||||
_publish_streaming_response(response_stream, workflow_run_id, app_mode=AppMode.ADVANCED_CHAT)
|
||||
_publish_streaming_response(
|
||||
response_stream,
|
||||
workflow_run_id,
|
||||
app_mode=AppMode.ADVANCED_CHAT,
|
||||
workflow_id="workflow-id",
|
||||
inputs={},
|
||||
started_reason=WorkflowStartReason.INITIAL,
|
||||
)
|
||||
|
||||
payloads = [call.args[0] for call in mock_topic.publish.call_args_list]
|
||||
assert payloads == [json.dumps({"event": "foo"}).encode(), json.dumps("ping").encode()]
|
||||
payloads = _published_payloads(mock_topic)
|
||||
assert [payload["event"] for payload in payloads] == ["workflow_started", "workflow_finished"]
|
||||
|
||||
|
||||
def test_publish_streaming_response_coerces_string_uuid(mock_topic: MagicMock):
|
||||
workflow_run_id = uuid.uuid4()
|
||||
response_stream = iter([{"event": "bar"}])
|
||||
response_stream = iter([{"event": "workflow_paused", "task_id": "task-id"}])
|
||||
|
||||
_publish_streaming_response(response_stream, str(workflow_run_id), app_mode=AppMode.ADVANCED_CHAT)
|
||||
_publish_streaming_response(
|
||||
response_stream,
|
||||
str(workflow_run_id),
|
||||
app_mode=AppMode.ADVANCED_CHAT,
|
||||
workflow_id="workflow-id",
|
||||
inputs={},
|
||||
started_reason=WorkflowStartReason.INITIAL,
|
||||
)
|
||||
|
||||
mock_topic.publish.assert_called_once_with(json.dumps({"event": "bar"}).encode())
|
||||
payloads = _published_payloads(mock_topic)
|
||||
assert [payload["event"] for payload in payloads] == ["workflow_paused"]
|
||||
|
||||
|
||||
def test_publish_streaming_response_publishes_started_then_failed_terminal_when_iteration_raises(
|
||||
mock_topic: MagicMock,
|
||||
):
|
||||
def _response_stream():
|
||||
if False:
|
||||
yield None
|
||||
raise RuntimeError("stream exploded")
|
||||
|
||||
with pytest.raises(RuntimeError, match="stream exploded"):
|
||||
_publish_streaming_response(
|
||||
_response_stream(),
|
||||
"workflow-run-id",
|
||||
app_mode=AppMode.ADVANCED_CHAT,
|
||||
workflow_id="workflow-id",
|
||||
inputs={"foo": "bar"},
|
||||
started_reason=WorkflowStartReason.INITIAL,
|
||||
)
|
||||
|
||||
payloads = _published_payloads(mock_topic)
|
||||
assert [payload["event"] for payload in payloads] == ["workflow_started", "workflow_finished"]
|
||||
assert payloads[0]["data"]["workflow_id"] == "workflow-id"
|
||||
assert payloads[0]["data"]["inputs"] == {"foo": "bar"}
|
||||
assert payloads[1]["data"]["status"] == WorkflowExecutionStatus.FAILED
|
||||
assert payloads[1]["data"]["error"] == "stream exploded"
|
||||
|
||||
|
||||
def test_publish_streaming_response_recovers_when_workflow_started_publish_fails_first(
|
||||
mock_topic: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
caplog.set_level(logging.ERROR, logger="tasks.app_generate.workflow_execute_task")
|
||||
response_stream = iter([{"event": "workflow_started", "task_id": "task-id"}])
|
||||
successful_payloads: list[dict[str, object] | str] = []
|
||||
started_publish_attempts = 0
|
||||
|
||||
def _publish(payload: bytes) -> None:
|
||||
nonlocal started_publish_attempts
|
||||
|
||||
decoded = _decode_published_payload(payload)
|
||||
if isinstance(decoded, dict) and decoded.get("event") == "workflow_started":
|
||||
started_publish_attempts += 1
|
||||
if started_publish_attempts == 1:
|
||||
raise RuntimeError("started publish failed")
|
||||
successful_payloads.append(decoded)
|
||||
|
||||
mock_topic.publish.side_effect = _publish
|
||||
|
||||
with pytest.raises(RuntimeError, match="started publish failed"):
|
||||
_publish_streaming_response(
|
||||
response_stream,
|
||||
"workflow-run-id",
|
||||
app_mode=AppMode.ADVANCED_CHAT,
|
||||
workflow_id="workflow-id",
|
||||
inputs={"file": object()},
|
||||
started_reason=WorkflowStartReason.INITIAL,
|
||||
)
|
||||
|
||||
assert [payload["event"] for payload in successful_payloads] == ["workflow_started", "workflow_finished"]
|
||||
assert successful_payloads[0]["task_id"] == "task-id"
|
||||
assert isinstance(successful_payloads[0]["data"]["inputs"]["file"], str)
|
||||
assert successful_payloads[1]["task_id"] == "task-id"
|
||||
assert successful_payloads[1]["data"]["status"] == WorkflowExecutionStatus.FAILED
|
||||
assert successful_payloads[1]["data"]["error"] == "started publish failed"
|
||||
assert "workflow-run-id" in caplog.text
|
||||
assert "publishing fallback terminal event" in caplog.text
|
||||
|
||||
|
||||
def test_publish_streaming_response_publishes_failed_terminal_without_duplicate_started_on_publish_error(
|
||||
mock_topic: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
caplog.set_level(logging.ERROR, logger="tasks.app_generate.workflow_execute_task")
|
||||
response_stream = iter(
|
||||
[
|
||||
{
|
||||
"event": "workflow_started",
|
||||
"task_id": "task-id",
|
||||
"workflow_run_id": "workflow-run-id",
|
||||
"data": {"id": "workflow-run-id", "workflow_id": "workflow-id", "inputs": {}, "created_at": 1},
|
||||
},
|
||||
{"event": "node_started", "task_id": "task-id"},
|
||||
]
|
||||
)
|
||||
successful_payloads: list[dict[str, object] | str] = []
|
||||
|
||||
def _publish(payload: bytes) -> None:
|
||||
decoded = _decode_published_payload(payload)
|
||||
if isinstance(decoded, dict) and decoded.get("event") == "node_started":
|
||||
raise RuntimeError("broker write failed")
|
||||
successful_payloads.append(decoded)
|
||||
|
||||
mock_topic.publish.side_effect = _publish
|
||||
|
||||
with pytest.raises(RuntimeError, match="broker write failed"):
|
||||
_publish_streaming_response(
|
||||
response_stream,
|
||||
"workflow-run-id",
|
||||
app_mode=AppMode.ADVANCED_CHAT,
|
||||
workflow_id="workflow-id",
|
||||
inputs={},
|
||||
started_reason=WorkflowStartReason.INITIAL,
|
||||
)
|
||||
|
||||
assert [payload["event"] for payload in successful_payloads] == ["workflow_started", "workflow_finished"]
|
||||
assert successful_payloads[1]["task_id"] == "task-id"
|
||||
assert successful_payloads[1]["data"]["status"] == WorkflowExecutionStatus.FAILED
|
||||
assert successful_payloads[1]["data"]["error"] == "broker write failed"
|
||||
assert "workflow-run-id" in caplog.text
|
||||
assert "publishing fallback terminal event" in caplog.text
|
||||
|
||||
|
||||
def test_publish_streaming_response_recovers_when_workflow_finished_publish_fails_first(
|
||||
mock_topic: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
caplog.set_level(logging.ERROR, logger="tasks.app_generate.workflow_execute_task")
|
||||
response_stream = iter(
|
||||
[
|
||||
{"event": "workflow_started", "task_id": "task-id"},
|
||||
{"event": "workflow_finished", "task_id": "task-id", "data": {"status": "succeeded"}},
|
||||
]
|
||||
)
|
||||
successful_payloads: list[dict[str, object] | str] = []
|
||||
finished_publish_attempts = 0
|
||||
|
||||
def _publish(payload: bytes) -> None:
|
||||
nonlocal finished_publish_attempts
|
||||
|
||||
decoded = _decode_published_payload(payload)
|
||||
if isinstance(decoded, dict) and decoded.get("event") == "workflow_finished":
|
||||
finished_publish_attempts += 1
|
||||
if finished_publish_attempts == 1:
|
||||
raise RuntimeError("finished publish failed")
|
||||
successful_payloads.append(decoded)
|
||||
|
||||
mock_topic.publish.side_effect = _publish
|
||||
|
||||
with pytest.raises(RuntimeError, match="finished publish failed"):
|
||||
_publish_streaming_response(
|
||||
response_stream,
|
||||
"workflow-run-id",
|
||||
app_mode=AppMode.ADVANCED_CHAT,
|
||||
workflow_id="workflow-id",
|
||||
inputs={},
|
||||
started_reason=WorkflowStartReason.INITIAL,
|
||||
)
|
||||
|
||||
assert [payload["event"] for payload in successful_payloads] == ["workflow_started", "workflow_finished"]
|
||||
assert successful_payloads[1]["task_id"] == "task-id"
|
||||
assert successful_payloads[1]["data"]["status"] == WorkflowExecutionStatus.FAILED
|
||||
assert successful_payloads[1]["data"]["error"] == "finished publish failed"
|
||||
assert "workflow-run-id" in caplog.text
|
||||
assert "publishing fallback terminal event" in caplog.text
|
||||
|
||||
|
||||
def test_publish_streaming_response_publishes_failed_terminal_on_exhaustion_without_terminal_event(
|
||||
mock_topic: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
caplog.set_level(logging.WARNING, logger="tasks.app_generate.workflow_execute_task")
|
||||
response_stream = iter(
|
||||
[
|
||||
{
|
||||
"event": "workflow_started",
|
||||
"task_id": "task-id",
|
||||
"workflow_run_id": "workflow-run-id",
|
||||
"data": {"id": "workflow-run-id", "workflow_id": "workflow-id", "inputs": {}, "created_at": 1},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
_publish_streaming_response(
|
||||
response_stream,
|
||||
"workflow-run-id",
|
||||
app_mode=AppMode.ADVANCED_CHAT,
|
||||
workflow_id="workflow-id",
|
||||
inputs={},
|
||||
started_reason=WorkflowStartReason.INITIAL,
|
||||
)
|
||||
|
||||
payloads = _published_payloads(mock_topic)
|
||||
assert [payload["event"] for payload in payloads] == ["workflow_started", "workflow_finished"]
|
||||
assert payloads[1]["task_id"] == "task-id"
|
||||
assert payloads[1]["data"]["status"] == WorkflowExecutionStatus.FAILED
|
||||
assert payloads[1]["data"]["error"] == "Workflow stream ended without a terminal event"
|
||||
assert "workflow-run-id" in caplog.text
|
||||
assert "ended without a terminal event" in caplog.text
|
||||
|
||||
|
||||
def test_publish_streaming_response_does_not_publish_synthetic_failure_after_terminal_event(mock_topic: MagicMock):
|
||||
response_stream = iter(
|
||||
[
|
||||
{
|
||||
"event": "workflow_started",
|
||||
"task_id": "task-id",
|
||||
"workflow_run_id": "workflow-run-id",
|
||||
"data": {"id": "workflow-run-id", "workflow_id": "workflow-id", "inputs": {}, "created_at": 1},
|
||||
},
|
||||
{
|
||||
"event": "workflow_finished",
|
||||
"task_id": "task-id",
|
||||
"workflow_run_id": "workflow-run-id",
|
||||
"data": {
|
||||
"id": "workflow-run-id",
|
||||
"workflow_id": "workflow-id",
|
||||
"status": WorkflowExecutionStatus.SUCCEEDED,
|
||||
"outputs": {},
|
||||
"error": None,
|
||||
"elapsed_time": 0.1,
|
||||
"total_tokens": 1,
|
||||
"total_steps": 1,
|
||||
"created_by": {},
|
||||
"created_at": 1,
|
||||
"finished_at": 2,
|
||||
"exceptions_count": 0,
|
||||
"files": [],
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
_publish_streaming_response(
|
||||
response_stream,
|
||||
"workflow-run-id",
|
||||
app_mode=AppMode.ADVANCED_CHAT,
|
||||
workflow_id="workflow-id",
|
||||
inputs={},
|
||||
started_reason=WorkflowStartReason.INITIAL,
|
||||
)
|
||||
|
||||
payloads = _published_payloads(mock_topic)
|
||||
assert [payload["event"] for payload in payloads] == ["workflow_started", "workflow_finished"]
|
||||
|
||||
|
||||
def test_app_runner_streaming_failure_publishes_started_then_failed_workflow_finished(
|
||||
mock_topic: MagicMock, monkeypatch
|
||||
):
|
||||
exec_params = AppExecutionParams(
|
||||
app_id="app-id",
|
||||
workflow_id="workflow-id",
|
||||
tenant_id="tenant-id",
|
||||
app_mode=AppMode.ADVANCED_CHAT,
|
||||
user={"TYPE": "account", "user_id": "user-id"},
|
||||
args={"inputs": {}, "query": "test"},
|
||||
invoke_from=InvokeFrom.EXPLORE,
|
||||
streaming=True,
|
||||
workflow_run_id="workflow-run-id",
|
||||
)
|
||||
runner = _AppRunner(session_factory=MagicMock(), exec_params=exec_params)
|
||||
|
||||
workflow = SimpleNamespace(id="workflow-id", app_id="app-id", created_by="workflow-owner")
|
||||
app = SimpleNamespace(id="app-id")
|
||||
fake_session = MagicMock()
|
||||
fake_session.get.side_effect = [workflow, app]
|
||||
|
||||
monkeypatch.setattr(runner, "_session", lambda: nullcontext(fake_session))
|
||||
monkeypatch.setattr(runner, "_resolve_user", lambda: MagicMock())
|
||||
monkeypatch.setattr(runner, "_setup_flask_context", lambda _user: nullcontext())
|
||||
monkeypatch.setattr(runner, "_run_app", lambda **_kwargs: (_ for _ in ()).throw(ValueError("Invalid upload file")))
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid upload file"):
|
||||
runner.run()
|
||||
|
||||
assert mock_topic.publish.call_count == 2
|
||||
started_payload = json.loads(mock_topic.publish.call_args_list[0].args[0].decode())
|
||||
assert started_payload["event"] == "workflow_started"
|
||||
assert started_payload["workflow_run_id"] == "workflow-run-id"
|
||||
assert started_payload["task_id"] == "workflow-run-id"
|
||||
assert started_payload["data"]["id"] == "workflow-run-id"
|
||||
assert started_payload["data"]["workflow_id"] == "workflow-id"
|
||||
assert started_payload["data"]["reason"] == "initial"
|
||||
|
||||
finished_payload = json.loads(mock_topic.publish.call_args_list[1].args[0].decode())
|
||||
assert finished_payload["event"] == "workflow_finished"
|
||||
assert finished_payload["workflow_run_id"] == "workflow-run-id"
|
||||
assert finished_payload["task_id"] == "workflow-run-id"
|
||||
assert finished_payload["data"]["id"] == "workflow-run-id"
|
||||
assert finished_payload["data"]["workflow_id"] == "workflow-id"
|
||||
assert finished_payload["data"]["status"] == WorkflowExecutionStatus.FAILED
|
||||
assert finished_payload["data"]["error"] == "Invalid upload file"
|
||||
assert finished_payload["data"]["outputs"] is None
|
||||
assert finished_payload["data"]["total_tokens"] == 0
|
||||
assert finished_payload["data"]["total_steps"] == 0
|
||||
assert finished_payload["data"]["exceptions_count"] == 1
|
||||
assert finished_payload["data"]["created_by"] == {}
|
||||
assert finished_payload["data"]["created_at"] == finished_payload["data"]["finished_at"]
|
||||
assert finished_payload["data"]["files"] == []
|
||||
|
||||
|
||||
def test_app_runner_streaming_failure_keeps_existing_pre_runtime_helper_behavior(
|
||||
mock_topic: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
exec_params = AppExecutionParams(
|
||||
app_id="app-id",
|
||||
workflow_id="workflow-id",
|
||||
tenant_id="tenant-id",
|
||||
app_mode=AppMode.ADVANCED_CHAT,
|
||||
user={"TYPE": "account", "user_id": "user-id"},
|
||||
args={"inputs": {}, "query": "test"},
|
||||
invoke_from=InvokeFrom.EXPLORE,
|
||||
streaming=True,
|
||||
workflow_run_id="workflow-run-id",
|
||||
)
|
||||
runner = _AppRunner(session_factory=MagicMock(), exec_params=exec_params)
|
||||
|
||||
workflow = SimpleNamespace(id="workflow-id", app_id="app-id", created_by="workflow-owner")
|
||||
app = SimpleNamespace(id="app-id")
|
||||
fake_session = MagicMock()
|
||||
fake_session.get.side_effect = [workflow, app]
|
||||
|
||||
monkeypatch.setattr(runner, "_session", lambda: nullcontext(fake_session))
|
||||
monkeypatch.setattr(runner, "_resolve_user", lambda: MagicMock())
|
||||
monkeypatch.setattr(runner, "_setup_flask_context", lambda _user: nullcontext())
|
||||
monkeypatch.setattr(runner, "_run_app", lambda **_kwargs: (_ for _ in ()).throw(ValueError("Invalid upload file")))
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.workflow_entry.WorkflowEntry.handle_special_values",
|
||||
lambda value: (_ for _ in ()).throw(AssertionError("pre-runtime helper should not normalize inputs")),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid upload file"):
|
||||
runner.run()
|
||||
|
||||
payloads = _published_payloads(mock_topic)
|
||||
assert payloads[0]["data"]["inputs"] == {}
|
||||
assert payloads[0]["data"]["reason"] == WorkflowStartReason.INITIAL
|
||||
|
||||
|
||||
def test_app_runner_streaming_success_calls_publish_streaming_response_with_full_signature(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
exec_params = AppExecutionParams(
|
||||
app_id="app-id",
|
||||
workflow_id="workflow-id",
|
||||
tenant_id="tenant-id",
|
||||
app_mode=AppMode.ADVANCED_CHAT,
|
||||
user={"TYPE": "account", "user_id": "user-id"},
|
||||
args={"inputs": {"foo": "bar"}, "query": "test"},
|
||||
invoke_from=InvokeFrom.EXPLORE,
|
||||
streaming=True,
|
||||
workflow_run_id="workflow-run-id",
|
||||
)
|
||||
runner = _AppRunner(session_factory=MagicMock(), exec_params=exec_params)
|
||||
|
||||
workflow = SimpleNamespace(id="workflow-id", app_id="app-id", created_by="workflow-owner")
|
||||
app = SimpleNamespace(id="app-id")
|
||||
fake_session = MagicMock()
|
||||
fake_session.get.side_effect = [workflow, app]
|
||||
response_stream = _single_event_generator({"event": "message"})
|
||||
publish_streaming_response = MagicMock()
|
||||
|
||||
monkeypatch.setattr(runner, "_session", lambda: nullcontext(fake_session))
|
||||
monkeypatch.setattr(runner, "_resolve_user", lambda: MagicMock())
|
||||
monkeypatch.setattr(runner, "_setup_flask_context", lambda _user: nullcontext())
|
||||
monkeypatch.setattr(runner, "_run_app", lambda **_kwargs: response_stream)
|
||||
monkeypatch.setattr(
|
||||
"tasks.app_generate.workflow_execute_task._publish_streaming_response",
|
||||
publish_streaming_response,
|
||||
)
|
||||
|
||||
runner.run()
|
||||
|
||||
publish_streaming_response.assert_called_once_with(
|
||||
response_stream,
|
||||
exec_params.workflow_run_id,
|
||||
exec_params.app_mode,
|
||||
exec_params.workflow_id,
|
||||
exec_params.args.get("inputs", {}),
|
||||
WorkflowStartReason.INITIAL,
|
||||
)
|
||||
|
||||
|
||||
def test_resume_app_execution_queries_message_by_conversation_and_workflow_run(monkeypatch: pytest.MonkeyPatch):
|
||||
@ -247,6 +692,7 @@ def test_resume_app_execution_returns_early_when_advanced_chat_missing_conversat
|
||||
def test_resume_advanced_chat_publishes_events_for_originally_blocking_runs(monkeypatch: pytest.MonkeyPatch):
|
||||
generate_entity = _build_advanced_chat_generate_entity(conversation_id="conversation-id")
|
||||
generate_entity.stream = False
|
||||
workflow = SimpleNamespace(id="workflow-id", created_by="workflow-owner")
|
||||
|
||||
generator_instance = MagicMock()
|
||||
response_stream = _single_event_generator({"event": "message"})
|
||||
@ -271,7 +717,7 @@ def test_resume_advanced_chat_publishes_events_for_originally_blocking_runs(monk
|
||||
|
||||
_resume_advanced_chat(
|
||||
app_model=SimpleNamespace(id="app-id"),
|
||||
workflow=SimpleNamespace(created_by="workflow-owner"),
|
||||
workflow=workflow,
|
||||
user=MagicMock(),
|
||||
conversation=SimpleNamespace(id="conversation-id"),
|
||||
message=MagicMock(),
|
||||
@ -285,11 +731,19 @@ def test_resume_advanced_chat_publishes_events_for_originally_blocking_runs(monk
|
||||
|
||||
resumed_entity = generator_instance.resume.call_args.kwargs["application_generate_entity"]
|
||||
assert resumed_entity.stream is True
|
||||
publish_streaming_response.assert_called_once_with(response_stream, "workflow-run-id", AppMode.ADVANCED_CHAT)
|
||||
publish_streaming_response.assert_called_once_with(
|
||||
response_stream,
|
||||
"workflow-run-id",
|
||||
AppMode.ADVANCED_CHAT,
|
||||
workflow.id,
|
||||
generate_entity.inputs,
|
||||
WorkflowStartReason.RESUMPTION,
|
||||
)
|
||||
|
||||
|
||||
def test_resume_workflow_publishes_events_for_originally_blocking_runs(monkeypatch: pytest.MonkeyPatch):
|
||||
generate_entity = _build_workflow_generate_entity(stream=False)
|
||||
workflow = SimpleNamespace(id="workflow-id", created_by="workflow-owner")
|
||||
|
||||
generator_instance = MagicMock()
|
||||
response_stream = _single_event_generator({"event": "workflow_finished"})
|
||||
@ -316,7 +770,7 @@ def test_resume_workflow_publishes_events_for_originally_blocking_runs(monkeypat
|
||||
|
||||
_resume_workflow(
|
||||
app_model=SimpleNamespace(id="app-id"),
|
||||
workflow=SimpleNamespace(created_by="workflow-owner"),
|
||||
workflow=workflow,
|
||||
user=MagicMock(),
|
||||
generate_entity=generate_entity,
|
||||
graph_runtime_state=MagicMock(),
|
||||
@ -330,12 +784,20 @@ def test_resume_workflow_publishes_events_for_originally_blocking_runs(monkeypat
|
||||
|
||||
resumed_entity = generator_instance.resume.call_args.kwargs["application_generate_entity"]
|
||||
assert resumed_entity.stream is True
|
||||
publish_streaming_response.assert_called_once_with(response_stream, "workflow-run-id", AppMode.WORKFLOW)
|
||||
publish_streaming_response.assert_called_once_with(
|
||||
response_stream,
|
||||
"workflow-run-id",
|
||||
AppMode.WORKFLOW,
|
||||
workflow.id,
|
||||
generate_entity.inputs,
|
||||
WorkflowStartReason.RESUMPTION,
|
||||
)
|
||||
workflow_run_repo.delete_workflow_pause.assert_called_once_with(pause_entity)
|
||||
|
||||
|
||||
def test_resume_workflow_ignores_missing_old_pause_after_repause(monkeypatch: pytest.MonkeyPatch):
|
||||
generate_entity = _build_workflow_generate_entity(stream=False)
|
||||
workflow = SimpleNamespace(id="workflow-id", created_by="workflow-owner")
|
||||
|
||||
generator_instance = MagicMock()
|
||||
response_stream = _single_event_generator({"event": "workflow_paused"})
|
||||
@ -363,7 +825,7 @@ def test_resume_workflow_ignores_missing_old_pause_after_repause(monkeypatch: py
|
||||
|
||||
_resume_workflow(
|
||||
app_model=SimpleNamespace(id="app-id"),
|
||||
workflow=SimpleNamespace(created_by="workflow-owner"),
|
||||
workflow=workflow,
|
||||
user=MagicMock(),
|
||||
generate_entity=generate_entity,
|
||||
graph_runtime_state=MagicMock(),
|
||||
@ -375,5 +837,12 @@ def test_resume_workflow_ignores_missing_old_pause_after_repause(monkeypatch: py
|
||||
pause_entity=pause_entity,
|
||||
)
|
||||
|
||||
publish_streaming_response.assert_called_once_with(response_stream, "workflow-run-id", AppMode.WORKFLOW)
|
||||
publish_streaming_response.assert_called_once_with(
|
||||
response_stream,
|
||||
"workflow-run-id",
|
||||
AppMode.WORKFLOW,
|
||||
workflow.id,
|
||||
generate_entity.inputs,
|
||||
WorkflowStartReason.RESUMPTION,
|
||||
)
|
||||
workflow_run_repo.delete_workflow_pause.assert_called_once_with(pause_entity)
|
||||
|
||||
2
api/uv.lock
generated
2
api/uv.lock
generated
@ -1619,7 +1619,7 @@ vdb-xinference = [
|
||||
requires-dist = [
|
||||
{ name = "aliyun-log-python-sdk", specifier = "==0.9.44" },
|
||||
{ name = "azure-identity", specifier = ">=1.25.3,<2.0.0" },
|
||||
{ name = "bleach", specifier = ">=6.3.0,<7.0.0" },
|
||||
{ name = "bleach", specifier = ">=6.4.0,<7.0.0" },
|
||||
{ name = "boto3", specifier = ">=1.43.24,<2.0.0" },
|
||||
{ name = "celery", specifier = ">=5.6.3,<6.0.0" },
|
||||
{ name = "croniter", specifier = ">=6.2.2,<7.0.0" },
|
||||
|
||||
@ -128,7 +128,7 @@ describe('AppDetailLayout', () => {
|
||||
expect(useStore.getState().appDetail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should allow users with monitor access to open logs directly', async () => {
|
||||
it('should redirect logs pages when log and annotation access is missing', async () => {
|
||||
mockPathname = '/app/app-1/logs'
|
||||
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.Monitor] }))
|
||||
|
||||
@ -138,9 +138,26 @@ describe('AppDetailLayout', () => {
|
||||
</AppDetailLayout>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/app/app-1/overview')
|
||||
})
|
||||
expect(screen.queryByText('App page content')).not.toBeInTheDocument()
|
||||
expect(useStore.getState().appDetail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should allow users with log and annotation access to open logs directly', async () => {
|
||||
mockPathname = '/app/app-1/logs'
|
||||
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.LogAndAnnotation] }))
|
||||
|
||||
render(
|
||||
<AppDetailLayout appId="app-1">
|
||||
<div>App page content</div>
|
||||
</AppDetailLayout>,
|
||||
)
|
||||
|
||||
await waitForAppContent()
|
||||
|
||||
expect(mockReplace).not.toHaveBeenCalledWith('/app/app-1/overview')
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
expect(useStore.getState().appDetail?.id).toBe('app-1')
|
||||
})
|
||||
|
||||
@ -289,7 +306,7 @@ describe('AppDetailLayout', () => {
|
||||
expect(useStore.getState().appDetail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should redirect annotation pages when edit access is missing', async () => {
|
||||
it('should redirect annotation pages when log and annotation access is missing', async () => {
|
||||
mockPathname = '/app/app-1/annotations'
|
||||
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({
|
||||
mode: AppModeEnum.CHAT,
|
||||
@ -309,11 +326,11 @@ describe('AppDetailLayout', () => {
|
||||
expect(useStore.getState().appDetail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should allow users with edit access to open annotations directly', async () => {
|
||||
it('should allow users with log and annotation access to open annotations directly', async () => {
|
||||
mockPathname = '/app/app-1/annotations'
|
||||
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({
|
||||
mode: AppModeEnum.CHAT,
|
||||
permission_keys: [AppACLPermission.Edit],
|
||||
permission_keys: [AppACLPermission.LogAndAnnotation],
|
||||
}))
|
||||
|
||||
render(
|
||||
|
||||
@ -108,8 +108,8 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
const isAccessConfigPath = pathname.endsWith('access-config')
|
||||
if (
|
||||
(isLayoutPath && !appACLCapabilities.canAccessLayout)
|
||||
|| (isLogsPath && !appACLCapabilities.canMonitor)
|
||||
|| (isAnnotationsPath && !appACLCapabilities.canEdit)
|
||||
|| (isLogsPath && !appACLCapabilities.canAccessLogAndAnnotation)
|
||||
|| (isAnnotationsPath && !appACLCapabilities.canAccessLogAndAnnotation)
|
||||
|| (isOverviewPath && !appACLCapabilities.canMonitor)
|
||||
|| (isAccessConfigPath && !appACLCapabilities.canAccessConfig)
|
||||
) {
|
||||
|
||||
@ -69,14 +69,34 @@ describe('OverviewView monitor permission', () => {
|
||||
expect(screen.queryByRole('button', { name: 'tracing' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render overview page content when app monitor permission is granted', () => {
|
||||
it('should render overview page content without tracing entry when only app monitor permission is granted', () => {
|
||||
testState.appDetail.permission_keys = [AppACLPermission.Monitor]
|
||||
|
||||
render(<OverviewView appId="app-1" />)
|
||||
|
||||
expect(screen.getByText('api key info panel')).toBeInTheDocument()
|
||||
expect(screen.getByText(/chart view app-1/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'tracing' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tracing entry when app tracing config permission is granted with monitor access', () => {
|
||||
testState.appDetail.permission_keys = [AppACLPermission.Monitor, AppACLPermission.TracingConfig]
|
||||
|
||||
render(<OverviewView appId="app-1" />)
|
||||
|
||||
expect(screen.getByText('api key info panel')).toBeInTheDocument()
|
||||
expect(screen.getByText(/chart view app-1/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'tracing' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render overview page content when only app tracing config permission is granted', () => {
|
||||
testState.appDetail.permission_keys = [AppACLPermission.TracingConfig]
|
||||
|
||||
render(<OverviewView appId="app-1" />)
|
||||
|
||||
expect(screen.queryByText('api key info panel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/chart view app-1/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'tracing' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -122,11 +122,24 @@ describe('Tracing overview panel permissions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('allows tracing config when app ACL includes monitor permission', async () => {
|
||||
it('marks tracing config as read-only with app monitor permission only', async () => {
|
||||
testState.appPermissionKeys = [AppACLPermission.Monitor]
|
||||
|
||||
await renderPanel()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(testState.configButtonProps[0]).toMatchObject({
|
||||
readOnly: true,
|
||||
hasConfigured: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('allows tracing config when app ACL includes tracing config permission', async () => {
|
||||
testState.appPermissionKeys = [AppACLPermission.TracingConfig]
|
||||
|
||||
await renderPanel()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(testState.configButtonProps[0]).toMatchObject({
|
||||
readOnly: false,
|
||||
|
||||
@ -47,7 +47,7 @@ const Panel: FC = () => {
|
||||
resourceMaintainer: appDetail?.maintainer,
|
||||
workspacePermissionKeys,
|
||||
}), [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys])
|
||||
const canConfigTracing = appACLCapabilities.canMonitor
|
||||
const canConfigTracing = appACLCapabilities.canConfigureTracing
|
||||
const readOnly = !canConfigTracing
|
||||
|
||||
const [isLoaded, {
|
||||
|
||||
@ -16,13 +16,13 @@ const OverviewView = ({ appId }: OverviewViewProps) => {
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const canMonitor = React.useMemo(() => getAppACLCapabilities(appDetail?.permission_keys, {
|
||||
const appACLCapabilities = React.useMemo(() => getAppACLCapabilities(appDetail?.permission_keys, {
|
||||
currentUserId,
|
||||
resourceMaintainer: appDetail?.maintainer,
|
||||
workspacePermissionKeys,
|
||||
}).canMonitor, [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys])
|
||||
}), [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys])
|
||||
|
||||
if (!appDetail || !canMonitor)
|
||||
if (!appDetail || !appACLCapabilities.canMonitor)
|
||||
return null
|
||||
|
||||
return (
|
||||
@ -31,7 +31,7 @@ const OverviewView = ({ appId }: OverviewViewProps) => {
|
||||
<div className="min-h-0 flex-1">
|
||||
<ChartView
|
||||
appId={appId}
|
||||
headerRight={<TracingPanel />}
|
||||
headerRight={appACLCapabilities.canConfigureTracing ? <TracingPanel /> : null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -69,28 +69,30 @@ describe('AppDetailSection', () => {
|
||||
|
||||
// Rendering behavior for app detail navigation entries.
|
||||
describe('Rendering', () => {
|
||||
it('should render logs and overview for chat apps with app monitor permission', () => {
|
||||
it('should render only overview for chat apps with app monitor permission', () => {
|
||||
// Arrange
|
||||
mockAppMode = 'chat'
|
||||
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('link', { name: 'common.appMenus.overview' })).toHaveAttribute('href', '/app/app-1/overview')
|
||||
expect(screen.queryByRole('link', { name: 'common.appMenus.logs' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('separator')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should render logs and annotations for chat apps with app log and annotation permission', () => {
|
||||
// Arrange
|
||||
mockAppMode = 'chat'
|
||||
mockAppPermissionKeys = [AppACLPermission.LogAndAnnotation]
|
||||
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('link', { name: 'common.appMenus.logs' })).toHaveAttribute('href', '/app/app-1/logs')
|
||||
expect(screen.getByRole('link', { name: 'common.appMenus.overview' })).toHaveAttribute('href', '/app/app-1/overview')
|
||||
expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render annotations for chat apps with app edit permission', () => {
|
||||
// Arrange
|
||||
mockAppMode = 'chat'
|
||||
mockAppPermissionKeys = [AppACLPermission.Edit]
|
||||
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('link', { name: 'common.appMenus.annotations' })).toHaveAttribute('href', '/app/app-1/annotations')
|
||||
expect(screen.getByRole('link', { name: 'common.appMenus.annotations' })).toHaveAttribute('data-icon', 'Annotations')
|
||||
expect(screen.queryByRole('link', { name: 'common.appMenus.overview' })).not.toBeInTheDocument()
|
||||
@ -99,7 +101,7 @@ describe('AppDetailSection', () => {
|
||||
it('should render dividers before logs and after annotations for chat apps', () => {
|
||||
// Arrange
|
||||
mockAppMode = 'chat'
|
||||
mockAppPermissionKeys = [AppACLPermission.Monitor, AppACLPermission.Edit]
|
||||
mockAppPermissionKeys = [AppACLPermission.LogAndAnnotation]
|
||||
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
@ -111,6 +113,7 @@ describe('AppDetailSection', () => {
|
||||
it('should only render logs navigation for workflow apps', () => {
|
||||
// Arrange
|
||||
mockAppMode = 'workflow'
|
||||
mockAppPermissionKeys = [AppACLPermission.LogAndAnnotation]
|
||||
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
@ -123,6 +126,7 @@ describe('AppDetailSection', () => {
|
||||
it('should render dividers before and after logs for workflow apps', () => {
|
||||
// Arrange
|
||||
mockAppMode = 'workflow'
|
||||
mockAppPermissionKeys = [AppACLPermission.LogAndAnnotation]
|
||||
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
@ -134,6 +138,7 @@ describe('AppDetailSection', () => {
|
||||
it('should only render logs navigation for completion apps', () => {
|
||||
// Arrange
|
||||
mockAppMode = 'completion'
|
||||
mockAppPermissionKeys = [AppACLPermission.LogAndAnnotation]
|
||||
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
@ -143,9 +148,9 @@ describe('AppDetailSection', () => {
|
||||
expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render monitor group dividers without monitor or edit permission', () => {
|
||||
it('should not render log and annotation group dividers without log and annotation permission', () => {
|
||||
// Arrange
|
||||
mockAppPermissionKeys = []
|
||||
mockAppPermissionKeys = [AppACLPermission.Monitor]
|
||||
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
@ -154,19 +159,20 @@ describe('AppDetailSection', () => {
|
||||
expect(screen.queryAllByRole('separator')).toHaveLength(0)
|
||||
expect(screen.queryByRole('link', { name: 'common.appMenus.logs' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'common.appMenus.overview' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'common.appMenus.overview' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render logs for users with app monitor permission', () => {
|
||||
it('should render logs for users with app log and annotation permission', () => {
|
||||
// Arrange
|
||||
mockAppPermissionKeys = [AppACLPermission.Monitor]
|
||||
mockAppPermissionKeys = [AppACLPermission.LogAndAnnotation]
|
||||
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('link', { name: 'common.appMenus.logs' })).toHaveAttribute('href', '/app/app-1/logs')
|
||||
expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'common.appMenus.annotations' })).toHaveAttribute('href', '/app/app-1/annotations')
|
||||
expect(screen.queryByRole('link', { name: 'common.appMenus.overview' })).not.toBeInTheDocument()
|
||||
expect(screen.getAllByRole('separator')).toHaveLength(2)
|
||||
})
|
||||
|
||||
@ -225,6 +231,9 @@ describe('AppDetailSection', () => {
|
||||
})
|
||||
|
||||
it('should pass collapsed mode to app info and navigation links when collapsed', () => {
|
||||
// Arrange
|
||||
mockAppPermissionKeys = [AppACLPermission.LogAndAnnotation]
|
||||
|
||||
// Act
|
||||
render(<AppDetailSection expand={false} />)
|
||||
|
||||
|
||||
@ -111,7 +111,7 @@ const AppDetailSection = ({
|
||||
icon: RiTerminalBoxLine,
|
||||
selectedIcon: RiTerminalBoxFill,
|
||||
},
|
||||
...(appACLCapabilities.canMonitor
|
||||
...(appACLCapabilities.canAccessLogAndAnnotation
|
||||
? [{
|
||||
name: t('appMenus.logs', { ns: 'common' }),
|
||||
href: `/app/${appId}/logs`,
|
||||
@ -120,7 +120,7 @@ const AppDetailSection = ({
|
||||
}]
|
||||
: []
|
||||
),
|
||||
...(appACLCapabilities.canEdit && supportsAnnotations
|
||||
...(appACLCapabilities.canAccessLogAndAnnotation && supportsAnnotations
|
||||
? [{
|
||||
name: t('appMenus.annotations', { ns: 'common' }),
|
||||
href: `/app/${appId}/annotations`,
|
||||
|
||||
@ -687,6 +687,29 @@ describe('useChat', () => {
|
||||
expect(lastResponse!.workflowProcess?.status).toBe('failed')
|
||||
})
|
||||
|
||||
it('should store workflow finished error on workflow process state', async () => {
|
||||
let callbacks: HookCallbacks
|
||||
|
||||
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
|
||||
callbacks = options as HookCallbacks
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useChat())
|
||||
|
||||
act(() => {
|
||||
result.current.handleSend('test-url', { query: 'failed workflow' }, {})
|
||||
})
|
||||
|
||||
act(() => {
|
||||
callbacks.onWorkflowStarted({ workflow_run_id: 'wr-err', task_id: 't-err' })
|
||||
callbacks.onWorkflowFinished({ data: { status: 'failed', error: 'Invalid upload file' } })
|
||||
})
|
||||
|
||||
const lastResponse = result.current.chatList[1]
|
||||
expect(lastResponse!.workflowProcess?.status).toBe('failed')
|
||||
expect(lastResponse!.workflowProcess?.error).toBe('Invalid upload file')
|
||||
})
|
||||
|
||||
it('should insert and then replace child QA when sending with parent_message_id', () => {
|
||||
let callbacks: HookCallbacks
|
||||
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
|
||||
|
||||
@ -24,6 +24,21 @@ describe('WorkflowProcessItem', () => {
|
||||
expect(screen.queryByTestId('tracing-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render workflow error message as collapsed title when failed without tracing', () => {
|
||||
render(
|
||||
<WorkflowProcessItem
|
||||
data={{
|
||||
status: WorkflowRunningStatus.Failed,
|
||||
tracing: [],
|
||||
error: 'Invalid upload file',
|
||||
} as WorkflowProcess}
|
||||
expand={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('workflow-process-title')).toHaveTextContent('Invalid upload file')
|
||||
})
|
||||
|
||||
it('should render "Workflow Process" title and TracingPanel when expanded', () => {
|
||||
// We expect t('common.workflowProcess', { ns: 'workflow' }) to be called
|
||||
render(<WorkflowProcessItem data={mockData as WorkflowProcess} expand={true} />)
|
||||
@ -31,6 +46,21 @@ describe('WorkflowProcessItem', () => {
|
||||
expect(screen.getByTestId('tracing-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render workflow error message when failed without node tracing details', () => {
|
||||
render(
|
||||
<WorkflowProcessItem
|
||||
data={{
|
||||
status: WorkflowRunningStatus.Failed,
|
||||
tracing: [],
|
||||
error: 'Invalid upload file',
|
||||
} as WorkflowProcess}
|
||||
expand={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Invalid upload file')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle collapse state on header click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<WorkflowProcessItem data={mockData as WorkflowProcess} expand={false} />)
|
||||
@ -89,7 +119,7 @@ describe('WorkflowProcessItem', () => {
|
||||
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-workflow-process-paused-bg')
|
||||
|
||||
rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Failed } as WorkflowProcess} />)
|
||||
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-workflow-process-failed-bg')
|
||||
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-[var(--color-workflow-process-failed-bg)]')
|
||||
})
|
||||
|
||||
it('should apply correct background when expanded for different statuses', () => {
|
||||
|
||||
@ -31,6 +31,10 @@ const WorkflowProcessItem = ({
|
||||
const failed = data.status === WorkflowRunningStatus.Failed || data.status === WorkflowRunningStatus.Stopped
|
||||
const paused = data.status === WorkflowRunningStatus.Paused
|
||||
const latestNode = data.tracing[data.tracing.length - 1]
|
||||
const fallbackTitle = t('common.workflowProcess', { ns: 'workflow' })
|
||||
const collapsedTitle = failed
|
||||
? data.error || latestNode?.error || latestNode?.title || fallbackTitle
|
||||
: latestNode?.title || fallbackTitle
|
||||
|
||||
useEffect(() => {
|
||||
setCollapse(!expand)
|
||||
@ -50,7 +54,7 @@ const WorkflowProcessItem = ({
|
||||
paused && !collapse && 'bg-state-warning-hover',
|
||||
collapse && !failed && !paused && 'bg-workflow-process-bg',
|
||||
collapse && paused && 'bg-workflow-process-paused-bg',
|
||||
collapse && failed && 'bg-workflow-process-failed-bg',
|
||||
collapse && failed && 'bg-[var(--color-workflow-process-failed-bg)]',
|
||||
)}
|
||||
data-testid="workflow-process-item"
|
||||
>
|
||||
@ -92,21 +96,38 @@ const WorkflowProcessItem = ({
|
||||
)
|
||||
}
|
||||
<div
|
||||
className="min-w-0 grow truncate system-xs-medium text-text-secondary"
|
||||
className={cn(
|
||||
'min-w-0 grow truncate system-xs-medium',
|
||||
collapse && failed && data.error ? 'text-text-destructive' : 'text-text-secondary',
|
||||
)}
|
||||
data-testid="workflow-process-title"
|
||||
>
|
||||
{!collapse ? t('common.workflowProcess', { ns: 'workflow' }) : latestNode?.title}
|
||||
{!collapse ? fallbackTitle : collapsedTitle}
|
||||
</div>
|
||||
<div className={cn('ml-1 i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary', !collapse && 'rotate-90')} />
|
||||
</div>
|
||||
{
|
||||
!collapse && (
|
||||
<div className="mt-1.5">
|
||||
<TracingPanel
|
||||
list={data.tracing}
|
||||
hideNodeInfo={hideInfo}
|
||||
hideNodeProcessDetail={hideProcessDetail}
|
||||
/>
|
||||
{
|
||||
failed && data.error && (
|
||||
<div
|
||||
className="mb-1.5 rounded-lg border-[0.5px] border-state-destructive-border bg-state-destructive-hover px-2 py-1.5 system-xs-regular text-text-destructive"
|
||||
data-testid="workflow-process-error"
|
||||
>
|
||||
{data.error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
data.tracing.length > 0 && (
|
||||
<TracingPanel
|
||||
list={data.tracing}
|
||||
hideNodeInfo={hideInfo}
|
||||
hideNodeProcessDetail={hideProcessDetail}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -405,7 +405,11 @@ export const useChat = (
|
||||
hasStopRespondedRef.current = false
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
if (responseItem.workflowProcess && responseItem.workflowProcess.tracing.length > 0) {
|
||||
responseItem.workflowProcess.status = WorkflowRunningStatus.Running
|
||||
responseItem.workflowProcess = {
|
||||
...responseItem.workflowProcess,
|
||||
status: WorkflowRunningStatus.Running,
|
||||
error: undefined,
|
||||
}
|
||||
}
|
||||
else {
|
||||
taskIdRef.current = task_id
|
||||
@ -419,8 +423,13 @@ export const useChat = (
|
||||
},
|
||||
onWorkflowFinished: ({ data: workflowFinishedData }) => {
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
if (responseItem.workflowProcess)
|
||||
responseItem.workflowProcess.status = workflowFinishedData.status as WorkflowRunningStatus
|
||||
if (responseItem.workflowProcess) {
|
||||
responseItem.workflowProcess = {
|
||||
...responseItem.workflowProcess,
|
||||
status: workflowFinishedData.status as WorkflowRunningStatus,
|
||||
error: workflowFinishedData.error,
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
onIterationStart: ({ data: iterationStartedData }) => {
|
||||
@ -971,7 +980,11 @@ export const useChat = (
|
||||
}
|
||||
|
||||
if (responseItem.workflowProcess && responseItem.workflowProcess.tracing.length > 0) {
|
||||
responseItem.workflowProcess.status = WorkflowRunningStatus.Running
|
||||
responseItem.workflowProcess = {
|
||||
...responseItem.workflowProcess,
|
||||
status: WorkflowRunningStatus.Running,
|
||||
error: undefined,
|
||||
}
|
||||
}
|
||||
else {
|
||||
taskIdRef.current = task_id
|
||||
@ -991,7 +1004,11 @@ export const useChat = (
|
||||
onWorkflowFinished: ({ data: workflowFinishedData }) => {
|
||||
if (pausedStateRef.current)
|
||||
pausedStateRef.current = false
|
||||
responseItem.workflowProcess!.status = workflowFinishedData.status as WorkflowRunningStatus
|
||||
responseItem.workflowProcess = {
|
||||
...responseItem.workflowProcess!,
|
||||
status: workflowFinishedData.status as WorkflowRunningStatus,
|
||||
error: workflowFinishedData.error,
|
||||
}
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
|
||||
@ -38,6 +38,7 @@ export type ChatConfig = Omit<ModelConfig, 'model'> & {
|
||||
export type WorkflowProcess = {
|
||||
status: WorkflowRunningStatus
|
||||
tracing: NodeTracing[]
|
||||
error?: string
|
||||
expand?: boolean // for UI
|
||||
resultText?: string
|
||||
files?: FileEntity[]
|
||||
|
||||
@ -11,6 +11,8 @@ const expectedAppACLPermissionKeys = [
|
||||
'app.acl.delete',
|
||||
'app.acl.release_and_version',
|
||||
'app.acl.monitor',
|
||||
'app.acl.tracing_config',
|
||||
'app.acl.log_and_annotation',
|
||||
'app.acl.access_config',
|
||||
]
|
||||
|
||||
|
||||
@ -635,6 +635,10 @@ describe('createWorkflowStreamHandlers', () => {
|
||||
message: 'failed',
|
||||
})
|
||||
expect(failureSetup.onCompleted).toHaveBeenCalledWith('', 3, false)
|
||||
expect(failureSetup.workflowProcessData()).toEqual(expect.objectContaining({
|
||||
status: WorkflowRunningStatus.Failed,
|
||||
error: 'failed',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should cover existing workflow starts, stopped runs, and non-string outputs', () => {
|
||||
|
||||
@ -33,6 +33,7 @@ type CreateWorkflowStreamHandlersParams = {
|
||||
const createInitialWorkflowProcess = (): WorkflowProcess => ({
|
||||
status: WorkflowRunningStatus.Running,
|
||||
tracing: [],
|
||||
error: undefined,
|
||||
expand: false,
|
||||
resultText: '',
|
||||
})
|
||||
@ -148,9 +149,11 @@ const markNodesStopped = (traces?: WorkflowProcess['tracing']) => {
|
||||
const applyWorkflowFinishedState = (
|
||||
current: WorkflowProcess | undefined,
|
||||
status: WorkflowRunningStatus,
|
||||
error?: string,
|
||||
) => {
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
draft.status = status
|
||||
draft.error = error
|
||||
if ([WorkflowRunningStatus.Stopped, WorkflowRunningStatus.Failed].includes(status))
|
||||
markNodesStopped(draft.tracing)
|
||||
})
|
||||
@ -162,6 +165,7 @@ const applyWorkflowOutputs = (
|
||||
) => {
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
draft.status = WorkflowRunningStatus.Succeeded
|
||||
draft.error = undefined
|
||||
draft.files = getFilesInLogs(outputs || []) as unknown as WorkflowProcess['files']
|
||||
})
|
||||
}
|
||||
@ -301,6 +305,7 @@ export const createWorkflowStreamHandlers = ({
|
||||
setWorkflowProcessData(updateWorkflowProcess(workflowProcessData, (draft) => {
|
||||
draft.expand = true
|
||||
draft.status = WorkflowRunningStatus.Running
|
||||
draft.error = undefined
|
||||
}))
|
||||
return
|
||||
}
|
||||
@ -342,14 +347,18 @@ export const createWorkflowStreamHandlers = ({
|
||||
|
||||
const workflowStatus = data.status as WorkflowRunningStatus | undefined
|
||||
if (workflowStatus === WorkflowRunningStatus.Stopped) {
|
||||
setWorkflowProcessData(applyWorkflowFinishedState(getWorkflowProcessData(), WorkflowRunningStatus.Stopped))
|
||||
setWorkflowProcessData(
|
||||
applyWorkflowFinishedState(getWorkflowProcessData(), WorkflowRunningStatus.Stopped, data.error),
|
||||
)
|
||||
finishWithFailure()
|
||||
return
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
notify({ type: 'error', message: data.error })
|
||||
setWorkflowProcessData(applyWorkflowFinishedState(getWorkflowProcessData(), WorkflowRunningStatus.Failed))
|
||||
setWorkflowProcessData(
|
||||
applyWorkflowFinishedState(getWorkflowProcessData(), WorkflowRunningStatus.Failed, data.error),
|
||||
)
|
||||
finishWithFailure()
|
||||
return
|
||||
}
|
||||
|
||||
@ -262,6 +262,27 @@ describe('useChat – handleResume', () => {
|
||||
const answer = result.current.chatList.find(item => item.id === 'msg-resume')
|
||||
expect(answer!.workflowProcess!.status).toBe('succeeded')
|
||||
})
|
||||
|
||||
it('should store workflow finished error on resume workflow process', async () => {
|
||||
const { result } = await setupResumeWithTree()
|
||||
|
||||
act(() => {
|
||||
capturedResumeOptions.onWorkflowStarted({
|
||||
workflow_run_id: 'wfr-2',
|
||||
task_id: 'task-2',
|
||||
})
|
||||
})
|
||||
|
||||
act(() => {
|
||||
capturedResumeOptions.onWorkflowFinished({
|
||||
data: { status: 'failed', error: 'Invalid upload file' },
|
||||
})
|
||||
})
|
||||
|
||||
const answer = result.current.chatList.find(item => item.id === 'msg-resume')
|
||||
expect(answer!.workflowProcess!.status).toBe('failed')
|
||||
expect(answer!.workflowProcess!.error).toBe('Invalid upload file')
|
||||
})
|
||||
})
|
||||
|
||||
describe('onData', () => {
|
||||
|
||||
@ -522,6 +522,19 @@ describe('useChat – handleSend SSE callbacks', () => {
|
||||
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
|
||||
expect(answer!.workflowProcess!.status).toBe('succeeded')
|
||||
})
|
||||
|
||||
it('should store workflow finished error on workflow process', () => {
|
||||
const { result } = setupAndSend()
|
||||
startWorkflow()
|
||||
|
||||
act(() => {
|
||||
capturedCallbacks.onWorkflowFinished({ data: { status: 'failed', error: 'Invalid upload file' } })
|
||||
})
|
||||
|
||||
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
|
||||
expect(answer!.workflowProcess!.status).toBe('failed')
|
||||
expect(answer!.workflowProcess!.error).toBe('Invalid upload file')
|
||||
})
|
||||
})
|
||||
|
||||
describe('onIterationStart / onIterationFinish', () => {
|
||||
|
||||
@ -61,9 +61,9 @@ export const useChat = (
|
||||
) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleRun } = useWorkflowRun()
|
||||
const hasStopResponded = useRef(false)
|
||||
const hasStopRespondedRef = useRef(false)
|
||||
const workflowStore = useWorkflowStore()
|
||||
const conversationId = useRef('')
|
||||
const conversationIdRef = useRef('')
|
||||
const taskIdRef = useRef('')
|
||||
const [isResponding, setIsResponding] = useState(false)
|
||||
const isRespondingRef = useRef(false)
|
||||
@ -72,7 +72,7 @@ export const useChat = (
|
||||
const canRun = useHooksStore(s => s.accessControl.canRun)
|
||||
const invalidAllLastRun = useInvalidAllLastRun(configsMap?.flowType, configsMap?.flowId)
|
||||
const { fetchInspectVars } = useSetWorkflowVarsWithValue()
|
||||
const [suggestedQuestions, setSuggestQuestions] = useState<string[]>([])
|
||||
const [suggestedQuestions, setSuggestedQuestions] = useState<string[]>([])
|
||||
const suggestedQuestionsAbortControllerRef = useRef<AbortController | null>(null)
|
||||
const {
|
||||
setIterTimes,
|
||||
@ -190,7 +190,7 @@ export const useChat = (
|
||||
}, [produceChatTreeNode])
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
hasStopResponded.current = true
|
||||
hasStopRespondedRef.current = true
|
||||
handleResponding(false)
|
||||
if (stopChat && taskIdRef.current)
|
||||
stopChat(taskIdRef.current)
|
||||
@ -203,13 +203,13 @@ export const useChat = (
|
||||
}, [handleResponding, setIterTimes, setLoopTimes, stopChat])
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
conversationId.current = ''
|
||||
conversationIdRef.current = ''
|
||||
taskIdRef.current = ''
|
||||
handleStop()
|
||||
setIterTimes(DEFAULT_ITER_TIMES)
|
||||
setLoopTimes(DEFAULT_LOOP_TIMES)
|
||||
setChatTree([])
|
||||
setSuggestQuestions([])
|
||||
setSuggestedQuestions([])
|
||||
}, [
|
||||
handleStop,
|
||||
setIterTimes,
|
||||
@ -353,7 +353,7 @@ export const useChat = (
|
||||
}
|
||||
|
||||
if (isFirstMessage && newConversationId)
|
||||
conversationId.current = newConversationId
|
||||
conversationIdRef.current = newConversationId
|
||||
|
||||
taskIdRef.current = taskId
|
||||
if (messageId)
|
||||
@ -403,17 +403,17 @@ export const useChat = (
|
||||
return
|
||||
}
|
||||
|
||||
if (config?.suggested_questions_after_answer?.enabled && !hasStopResponded.current && onGetSuggestedQuestions) {
|
||||
if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) {
|
||||
try {
|
||||
const { data }: any = await onGetSuggestedQuestions(
|
||||
responseItem.id,
|
||||
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
|
||||
)
|
||||
setSuggestQuestions(data)
|
||||
setSuggestedQuestions(data)
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (error) {
|
||||
setSuggestQuestions([])
|
||||
setSuggestedQuestions([])
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -439,7 +439,7 @@ export const useChat = (
|
||||
onWorkflowStarted: ({ workflow_run_id, task_id, conversation_id, message_id }) => {
|
||||
// If there are no streaming messages, we still need to set the conversation_id to avoid create a new conversation when regeneration in chat-flow.
|
||||
if (conversation_id) {
|
||||
conversationId.current = conversation_id
|
||||
conversationIdRef.current = conversation_id
|
||||
}
|
||||
if (message_id && !hasSetResponseId) {
|
||||
questionItem.id = `question-${message_id}`
|
||||
@ -450,7 +450,11 @@ export const useChat = (
|
||||
|
||||
if (responseItem.workflowProcess && responseItem.workflowProcess.tracing.length > 0) {
|
||||
handleResponding(true)
|
||||
responseItem.workflowProcess.status = WorkflowRunningStatus.Running
|
||||
responseItem.workflowProcess = {
|
||||
...responseItem.workflowProcess,
|
||||
status: WorkflowRunningStatus.Running,
|
||||
error: undefined,
|
||||
}
|
||||
}
|
||||
else {
|
||||
taskIdRef.current = task_id
|
||||
@ -468,7 +472,11 @@ export const useChat = (
|
||||
})
|
||||
},
|
||||
onWorkflowFinished: ({ data }) => {
|
||||
responseItem.workflowProcess!.status = data.status as WorkflowRunningStatus
|
||||
responseItem.workflowProcess = {
|
||||
...responseItem.workflowProcess!,
|
||||
status: data.status as WorkflowRunningStatus,
|
||||
error: data.error,
|
||||
}
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
@ -723,7 +731,7 @@ export const useChat = (
|
||||
})
|
||||
|
||||
if (newConversationId)
|
||||
conversationId.current = newConversationId
|
||||
conversationIdRef.current = newConversationId
|
||||
|
||||
if (taskId)
|
||||
taskIdRef.current = taskId
|
||||
@ -751,16 +759,16 @@ export const useChat = (
|
||||
if (hasError)
|
||||
return
|
||||
|
||||
if (config?.suggested_questions_after_answer?.enabled && !hasStopResponded.current && onGetSuggestedQuestions) {
|
||||
if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) {
|
||||
try {
|
||||
const { data }: any = await onGetSuggestedQuestions(
|
||||
messageId,
|
||||
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
|
||||
)
|
||||
setSuggestQuestions(data)
|
||||
setSuggestedQuestions(data)
|
||||
}
|
||||
catch {
|
||||
setSuggestQuestions([])
|
||||
setSuggestedQuestions([])
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -782,10 +790,14 @@ export const useChat = (
|
||||
},
|
||||
onWorkflowStarted: ({ workflow_run_id, task_id }) => {
|
||||
handleResponding(true)
|
||||
hasStopResponded.current = false
|
||||
hasStopRespondedRef.current = false
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
if (responseItem.workflowProcess && responseItem.workflowProcess.tracing.length > 0) {
|
||||
responseItem.workflowProcess.status = WorkflowRunningStatus.Running
|
||||
responseItem.workflowProcess = {
|
||||
...responseItem.workflowProcess,
|
||||
status: WorkflowRunningStatus.Running,
|
||||
error: undefined,
|
||||
}
|
||||
}
|
||||
else {
|
||||
taskIdRef.current = task_id
|
||||
@ -799,8 +811,13 @@ export const useChat = (
|
||||
},
|
||||
onWorkflowFinished: ({ data: workflowFinishedData }) => {
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
if (responseItem.workflowProcess)
|
||||
responseItem.workflowProcess.status = workflowFinishedData.status as WorkflowRunningStatus
|
||||
if (responseItem.workflowProcess) {
|
||||
responseItem.workflowProcess = {
|
||||
...responseItem.workflowProcess,
|
||||
status: workflowFinishedData.status as WorkflowRunningStatus,
|
||||
error: workflowFinishedData.error,
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
onIterationStart: ({ data: iterationStartedData }) => {
|
||||
@ -1004,7 +1021,7 @@ export const useChat = (
|
||||
}, [handleResume])
|
||||
|
||||
return {
|
||||
conversationId: conversationId.current,
|
||||
conversationId: conversationIdRef.current,
|
||||
chatList,
|
||||
setTargetMessageId,
|
||||
handleSwitchSibling,
|
||||
|
||||
@ -222,7 +222,7 @@
|
||||
"members.assignRolesModal.singleDescription": "حدد دورًا واحدًا لتعيينه لهذا العضو.",
|
||||
"members.assignRolesModal.title": "تعيين الأدوار",
|
||||
"members.datasetOperatorTip": "يمكنه إدارة قاعدة المعرفة فقط",
|
||||
"members.editRole": "Edit Role",
|
||||
"members.editRole": "تعديل الدور",
|
||||
"members.editorTip": "يمكنه بناء وتعديل التطبيقات",
|
||||
"members.email": "البريد الإلكتروني",
|
||||
"members.emailInvalid": "تنسيق البريد الإلكتروني غير صالح",
|
||||
@ -256,7 +256,7 @@
|
||||
"members.removeFromTeam": "إزالة من الفريق",
|
||||
"members.removeFromTeamConfirmDescription": "يرجى تأكيد إزالة هذا العضو. لا يمكن التراجع عن هذا الإجراء.",
|
||||
"members.removeFromTeamConfirmTitle": "إزالة {{memberName}} من الفريق",
|
||||
"members.role": "الأدوار",
|
||||
"members.role": "الدور",
|
||||
"members.roles": "الأدوار",
|
||||
"members.selectRole": "اختر دورًا",
|
||||
"members.sendInvite": "إرسال دعوة",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "حذف التطبيق",
|
||||
"app.acl.edit": "تعديل التطبيق وتنسيقه",
|
||||
"app.acl.import_export_dsl": "استيراد / تصدير DSL",
|
||||
"app.acl.monitor": "المراقبة والعمليات",
|
||||
"app.acl.monitor": "الوصول إلى صفحة المراقبة",
|
||||
"app.acl.tracing_config": "تكوين التتبع الخارجي",
|
||||
"app.acl.log_and_annotation": "الوصول إلى السجلات والتعليقات التوضيحية",
|
||||
"app.acl.preview": "معاينة التطبيق",
|
||||
"app.acl.release_and_version": "نشر التطبيق وإدارة الإصدارات",
|
||||
"app.acl.test_and_run": "اختبار التطبيق واستخدامه",
|
||||
|
||||
@ -256,7 +256,7 @@
|
||||
"members.removeFromTeam": "Vom Team entfernen",
|
||||
"members.removeFromTeamConfirmDescription": "Bestätige, dass dieses Mitglied entfernt werden soll. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"members.removeFromTeamConfirmTitle": "{{memberName}} aus dem Team entfernen",
|
||||
"members.role": "ROLLEN",
|
||||
"members.role": "ROLLE",
|
||||
"members.roles": "ROLLEN",
|
||||
"members.selectRole": "Wählen Sie eine Rolle aus",
|
||||
"members.sendInvite": "Einladung senden",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "App löschen",
|
||||
"app.acl.edit": "App bearbeiten und orchestrieren",
|
||||
"app.acl.import_export_dsl": "DSL importieren / exportieren",
|
||||
"app.acl.monitor": "Überwachung und Betrieb",
|
||||
"app.acl.monitor": "Auf Monitoring-Seite zugreifen",
|
||||
"app.acl.tracing_config": "Externes Tracing konfigurieren",
|
||||
"app.acl.log_and_annotation": "Auf Logs und Annotationen zugreifen",
|
||||
"app.acl.preview": "App-Vorschau",
|
||||
"app.acl.release_and_version": "App-Veröffentlichung und Versionsverwaltung",
|
||||
"app.acl.test_and_run": "App testen und verwenden",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "Delete app",
|
||||
"app.acl.edit": "Edit and orchestrate app",
|
||||
"app.acl.import_export_dsl": "Import / export DSL",
|
||||
"app.acl.monitor": "Monitoring and operations",
|
||||
"app.acl.monitor": "Access monitoring page",
|
||||
"app.acl.tracing_config": "Configure external tracing",
|
||||
"app.acl.log_and_annotation": "Access logs and annotations",
|
||||
"app.acl.preview": "Preview app",
|
||||
"app.acl.release_and_version": "App publishing and version management",
|
||||
"app.acl.test_and_run": "Test and use app",
|
||||
|
||||
@ -256,7 +256,7 @@
|
||||
"members.removeFromTeam": "Eliminar del espacio de trabajo",
|
||||
"members.removeFromTeamConfirmDescription": "Confirma que quieres eliminar a este miembro. Esta acción no se puede deshacer.",
|
||||
"members.removeFromTeamConfirmTitle": "Eliminar a {{memberName}} del equipo",
|
||||
"members.role": "ROLES",
|
||||
"members.role": "ROL",
|
||||
"members.roles": "ROLES",
|
||||
"members.selectRole": "Selecciona un rol",
|
||||
"members.sendInvite": "Enviar invitación",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "Eliminar app",
|
||||
"app.acl.edit": "Editar y orquestar la app",
|
||||
"app.acl.import_export_dsl": "Importar / exportar DSL",
|
||||
"app.acl.monitor": "Supervisión y operaciones",
|
||||
"app.acl.monitor": "Acceder a la página de supervisión",
|
||||
"app.acl.tracing_config": "Configurar tracing externo",
|
||||
"app.acl.log_and_annotation": "Acceder a logs y anotaciones",
|
||||
"app.acl.preview": "Previsualizar app",
|
||||
"app.acl.release_and_version": "Publicación de la app y gestión de versiones",
|
||||
"app.acl.test_and_run": "Probar y usar la app",
|
||||
|
||||
@ -256,7 +256,7 @@
|
||||
"members.removeFromTeam": "حذف از تیم",
|
||||
"members.removeFromTeamConfirmDescription": "حذف این عضو را تأیید کنید. این عمل قابل بازگشت نیست.",
|
||||
"members.removeFromTeamConfirmTitle": "حذف {{memberName}} از تیم",
|
||||
"members.role": "نقشها",
|
||||
"members.role": "نقش",
|
||||
"members.roles": "نقشها",
|
||||
"members.selectRole": "یک نقش انتخاب کنید",
|
||||
"members.sendInvite": "ارسال دعوت",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "حذف برنامه",
|
||||
"app.acl.edit": "ویرایش و هماهنگسازی برنامه",
|
||||
"app.acl.import_export_dsl": "وارد کردن / صادر کردن DSL",
|
||||
"app.acl.monitor": "نظارت و عملیات",
|
||||
"app.acl.monitor": "دسترسی به صفحه نظارت",
|
||||
"app.acl.tracing_config": "پیکربندی رهگیری خارجی",
|
||||
"app.acl.log_and_annotation": "دسترسی به گزارشها و حاشیهنویسیها",
|
||||
"app.acl.preview": "پیشنمایش برنامه",
|
||||
"app.acl.release_and_version": "انتشار برنامه و مدیریت نسخه",
|
||||
"app.acl.test_and_run": "آزمایش و استفاده از برنامه",
|
||||
|
||||
@ -256,7 +256,7 @@
|
||||
"members.removeFromTeam": "Retirer de l'équipe",
|
||||
"members.removeFromTeamConfirmDescription": "Confirmez le retrait de ce membre. Cette action est irréversible.",
|
||||
"members.removeFromTeamConfirmTitle": "Retirer {{memberName}} de l'équipe",
|
||||
"members.role": "RÔLES",
|
||||
"members.role": "RÔLE",
|
||||
"members.roles": "RÔLES",
|
||||
"members.selectRole": "Sélectionner un rôle",
|
||||
"members.sendInvite": "Envoyer une invitation",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "Supprimer l'application",
|
||||
"app.acl.edit": "Modifier et orchestrer l'application",
|
||||
"app.acl.import_export_dsl": "Importer / exporter le DSL",
|
||||
"app.acl.monitor": "Surveillance et exploitation",
|
||||
"app.acl.monitor": "Accéder à la page de surveillance",
|
||||
"app.acl.tracing_config": "Configurer le traçage externe",
|
||||
"app.acl.log_and_annotation": "Accéder aux journaux et annotations",
|
||||
"app.acl.preview": "Prévisualiser l'application",
|
||||
"app.acl.release_and_version": "Publication de l'application et gestion des versions",
|
||||
"app.acl.test_and_run": "Tester et utiliser l'application",
|
||||
|
||||
@ -256,7 +256,7 @@
|
||||
"members.removeFromTeam": "टीम से हटाएं",
|
||||
"members.removeFromTeamConfirmDescription": "इस सदस्य को हटाने की पुष्टि करें। यह कार्रवाई वापस नहीं की जा सकती।",
|
||||
"members.removeFromTeamConfirmTitle": "{{memberName}} को टीम से हटाएं",
|
||||
"members.role": "भूमिकाएं",
|
||||
"members.role": "भूमिका",
|
||||
"members.roles": "भूमिकाएं",
|
||||
"members.selectRole": "एक भूमिका चुनें",
|
||||
"members.sendInvite": "आमंत्रण भेजें",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "ऐप हटाएं",
|
||||
"app.acl.edit": "ऐप संपादित और ऑर्केस्ट्रेट करें",
|
||||
"app.acl.import_export_dsl": "DSL आयात / निर्यात करें",
|
||||
"app.acl.monitor": "निगरानी और संचालन",
|
||||
"app.acl.monitor": "मॉनिटरिंग पेज तक पहुंच",
|
||||
"app.acl.tracing_config": "बाहरी ट्रेसिंग कॉन्फ़िगर करें",
|
||||
"app.acl.log_and_annotation": "लॉग और एनोटेशन तक पहुंच",
|
||||
"app.acl.preview": "ऐप पूर्वावलोकन करें",
|
||||
"app.acl.release_and_version": "ऐप प्रकाशन और संस्करण प्रबंधन",
|
||||
"app.acl.test_and_run": "ऐप परीक्षण और उपयोग करें",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "Hapus aplikasi",
|
||||
"app.acl.edit": "Edit dan orkestrasikan aplikasi",
|
||||
"app.acl.import_export_dsl": "Impor / ekspor DSL",
|
||||
"app.acl.monitor": "Pemantauan dan operasi",
|
||||
"app.acl.monitor": "Akses halaman pemantauan",
|
||||
"app.acl.tracing_config": "Konfigurasikan tracing eksternal",
|
||||
"app.acl.log_and_annotation": "Akses log dan anotasi",
|
||||
"app.acl.preview": "Pratinjau aplikasi",
|
||||
"app.acl.release_and_version": "Penerbitan aplikasi dan manajemen versi",
|
||||
"app.acl.test_and_run": "Uji dan gunakan aplikasi",
|
||||
|
||||
@ -256,7 +256,7 @@
|
||||
"members.removeFromTeam": "Rimuovi dal team",
|
||||
"members.removeFromTeamConfirmDescription": "Conferma la rimozione di questo membro. Questa azione non può essere annullata.",
|
||||
"members.removeFromTeamConfirmTitle": "Rimuovi {{memberName}} dal team",
|
||||
"members.role": "RUOLI",
|
||||
"members.role": "RUOLO",
|
||||
"members.roles": "RUOLI",
|
||||
"members.selectRole": "Seleziona un ruolo",
|
||||
"members.sendInvite": "Invia Invito",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "Elimina app",
|
||||
"app.acl.edit": "Modifica e orchestra l'app",
|
||||
"app.acl.import_export_dsl": "Importa / esporta DSL",
|
||||
"app.acl.monitor": "Monitoraggio e operazioni",
|
||||
"app.acl.monitor": "Accedi alla pagina di monitoraggio",
|
||||
"app.acl.tracing_config": "Configura il tracing esterno",
|
||||
"app.acl.log_and_annotation": "Accedi a log e annotazioni",
|
||||
"app.acl.preview": "Anteprima app",
|
||||
"app.acl.release_and_version": "Pubblicazione dell'app e gestione delle versioni",
|
||||
"app.acl.test_and_run": "Testa e usa l'app",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "アプリを削除",
|
||||
"app.acl.edit": "アプリの編集とオーケストレーション",
|
||||
"app.acl.import_export_dsl": "DSLのインポート / エクスポート",
|
||||
"app.acl.monitor": "監視と運用",
|
||||
"app.acl.monitor": "監視ページへのアクセス",
|
||||
"app.acl.tracing_config": "外部トレーシングの設定",
|
||||
"app.acl.log_and_annotation": "ログと注釈へのアクセス",
|
||||
"app.acl.preview": "アプリをプレビュー",
|
||||
"app.acl.release_and_version": "アプリ公開とバージョン管理",
|
||||
"app.acl.test_and_run": "アプリのテストと使用",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "앱 삭제",
|
||||
"app.acl.edit": "앱 편집 및 오케스트레이션",
|
||||
"app.acl.import_export_dsl": "DSL 가져오기 / 내보내기",
|
||||
"app.acl.monitor": "모니터링 및 운영",
|
||||
"app.acl.monitor": "모니터링 페이지 접근",
|
||||
"app.acl.tracing_config": "외부 추적 구성",
|
||||
"app.acl.log_and_annotation": "로그 및 주석 접근",
|
||||
"app.acl.preview": "앱 미리보기",
|
||||
"app.acl.release_and_version": "앱 게시 및 버전 관리",
|
||||
"app.acl.test_and_run": "앱 테스트 및 사용",
|
||||
|
||||
@ -256,8 +256,8 @@
|
||||
"members.removeFromTeam": "Remove from team",
|
||||
"members.removeFromTeamConfirmDescription": "Bevestig dat je dit lid wilt verwijderen. Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"members.removeFromTeamConfirmTitle": "{{memberName}} uit team verwijderen",
|
||||
"members.role": "ROLES",
|
||||
"members.roles": "ROLES",
|
||||
"members.role": "ROL",
|
||||
"members.roles": "ROLLEN",
|
||||
"members.selectRole": "Selecteer een rol",
|
||||
"members.sendInvite": "Send Invite",
|
||||
"members.transferModal.codeLabel": "Verification code",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "App verwijderen",
|
||||
"app.acl.edit": "App bewerken en orkestreren",
|
||||
"app.acl.import_export_dsl": "DSL importeren / exporteren",
|
||||
"app.acl.monitor": "Monitoring en bewerkingen",
|
||||
"app.acl.monitor": "Monitoringpagina openen",
|
||||
"app.acl.tracing_config": "Externe tracing configureren",
|
||||
"app.acl.log_and_annotation": "Logs en annotaties openen",
|
||||
"app.acl.preview": "App voorbeeld bekijken",
|
||||
"app.acl.release_and_version": "App publiceren en versiebeheer",
|
||||
"app.acl.test_and_run": "App testen en gebruiken",
|
||||
|
||||
@ -256,7 +256,7 @@
|
||||
"members.removeFromTeam": "Usuń z zespołu",
|
||||
"members.removeFromTeamConfirmDescription": "Potwierdź usunięcie tego członka. Tej czynności nie można cofnąć.",
|
||||
"members.removeFromTeamConfirmTitle": "Usuń {{memberName}} z zespołu",
|
||||
"members.role": "ROLE",
|
||||
"members.role": "ROLA",
|
||||
"members.roles": "ROLE",
|
||||
"members.selectRole": "Wybierz rolę",
|
||||
"members.sendInvite": "Wyślij zaproszenie",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "Usuń aplikację",
|
||||
"app.acl.edit": "Edytuj i orkiestruj aplikację",
|
||||
"app.acl.import_export_dsl": "Importuj / eksportuj DSL",
|
||||
"app.acl.monitor": "Monitorowanie i operacje",
|
||||
"app.acl.monitor": "Dostęp do strony monitorowania",
|
||||
"app.acl.tracing_config": "Konfiguruj zewnętrzne śledzenie",
|
||||
"app.acl.log_and_annotation": "Dostęp do logów i adnotacji",
|
||||
"app.acl.preview": "Podgląd aplikacji",
|
||||
"app.acl.release_and_version": "Publikowanie aplikacji i zarządzanie wersjami",
|
||||
"app.acl.test_and_run": "Testuj i używaj aplikacji",
|
||||
|
||||
@ -256,7 +256,7 @@
|
||||
"members.removeFromTeam": "Remover da equipe",
|
||||
"members.removeFromTeamConfirmDescription": "Confirme a remoção deste membro. Esta ação não pode ser desfeita.",
|
||||
"members.removeFromTeamConfirmTitle": "Remover {{memberName}} da equipe",
|
||||
"members.role": "FUNÇÕES",
|
||||
"members.role": "FUNÇÃO",
|
||||
"members.roles": "FUNÇÕES",
|
||||
"members.selectRole": "Selecione uma função",
|
||||
"members.sendInvite": "Enviar Convite",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "Excluir aplicativo",
|
||||
"app.acl.edit": "Editar e orquestrar aplicativo",
|
||||
"app.acl.import_export_dsl": "Importar / exportar DSL",
|
||||
"app.acl.monitor": "Monitoramento e operações",
|
||||
"app.acl.monitor": "Acessar página de monitoramento",
|
||||
"app.acl.tracing_config": "Configurar rastreamento externo",
|
||||
"app.acl.log_and_annotation": "Acessar logs e anotações",
|
||||
"app.acl.preview": "Visualizar aplicativo",
|
||||
"app.acl.release_and_version": "Publicação de aplicativo e gerenciamento de versões",
|
||||
"app.acl.test_and_run": "Testar e usar aplicativo",
|
||||
|
||||
@ -256,7 +256,7 @@
|
||||
"members.removeFromTeam": "Elimină din echipă",
|
||||
"members.removeFromTeamConfirmDescription": "Confirmă eliminarea acestui membru. Această acțiune nu poate fi anulată.",
|
||||
"members.removeFromTeamConfirmTitle": "Elimină {{memberName}} din echipă",
|
||||
"members.role": "ROLURI",
|
||||
"members.role": "ROL",
|
||||
"members.roles": "ROLURI",
|
||||
"members.selectRole": "Selectați un rol",
|
||||
"members.sendInvite": "Trimite invitație",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "Șterge aplicația",
|
||||
"app.acl.edit": "Editează și orchestrează aplicația",
|
||||
"app.acl.import_export_dsl": "Importă / exportă DSL",
|
||||
"app.acl.monitor": "Monitorizare și operațiuni",
|
||||
"app.acl.monitor": "Accesează pagina de monitorizare",
|
||||
"app.acl.tracing_config": "Configurează tracing extern",
|
||||
"app.acl.log_and_annotation": "Accesează jurnale și adnotări",
|
||||
"app.acl.preview": "Previzualizează aplicația",
|
||||
"app.acl.release_and_version": "Publicarea aplicației și gestionarea versiunilor",
|
||||
"app.acl.test_and_run": "Testează și utilizează aplicația",
|
||||
|
||||
@ -256,7 +256,7 @@
|
||||
"members.removeFromTeam": "Удалить из команды",
|
||||
"members.removeFromTeamConfirmDescription": "Подтвердите удаление этого участника. Это действие нельзя отменить.",
|
||||
"members.removeFromTeamConfirmTitle": "Удалить {{memberName}} из команды",
|
||||
"members.role": "РОЛИ",
|
||||
"members.role": "РОЛЬ",
|
||||
"members.roles": "РОЛИ",
|
||||
"members.selectRole": "Выберите роль",
|
||||
"members.sendInvite": "Отправить приглашение",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "Удаление приложения",
|
||||
"app.acl.edit": "Редактирование и оркестрация приложения",
|
||||
"app.acl.import_export_dsl": "Импорт / экспорт DSL",
|
||||
"app.acl.monitor": "Мониторинг и эксплуатация",
|
||||
"app.acl.monitor": "Доступ к странице мониторинга",
|
||||
"app.acl.tracing_config": "Настройка внешнего трассинга",
|
||||
"app.acl.log_and_annotation": "Доступ к журналам и аннотациям",
|
||||
"app.acl.preview": "Предпросмотр приложения",
|
||||
"app.acl.release_and_version": "Публикация приложения и управление версиями",
|
||||
"app.acl.test_and_run": "Тестирование и использование приложения",
|
||||
|
||||
@ -256,7 +256,7 @@
|
||||
"members.removeFromTeam": "Odstrani iz ekipe",
|
||||
"members.removeFromTeamConfirmDescription": "Potrdite odstranitev tega člana. Tega dejanja ni mogoče razveljaviti.",
|
||||
"members.removeFromTeamConfirmTitle": "Odstrani {{memberName}} iz ekipe",
|
||||
"members.role": "VLOGE",
|
||||
"members.role": "VLOGA",
|
||||
"members.roles": "VLOGE",
|
||||
"members.selectRole": "Izberite vlogo",
|
||||
"members.sendInvite": "Pošlji povabilo",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "Izbriši aplikacijo",
|
||||
"app.acl.edit": "Uredi in orkestriraj aplikacijo",
|
||||
"app.acl.import_export_dsl": "Uvozi / izvozi DSL",
|
||||
"app.acl.monitor": "Spremljanje in operacije",
|
||||
"app.acl.monitor": "Dostop do strani za spremljanje",
|
||||
"app.acl.tracing_config": "Konfiguracija zunanjega sledenja",
|
||||
"app.acl.log_and_annotation": "Dostop do dnevnikov in opomb",
|
||||
"app.acl.preview": "Predogled aplikacije",
|
||||
"app.acl.release_and_version": "Objavljanje aplikacije in upravljanje različic",
|
||||
"app.acl.test_and_run": "Preizkusi in uporabi aplikacijo",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "ลบแอป",
|
||||
"app.acl.edit": "แก้ไขและจัดวางแอป",
|
||||
"app.acl.import_export_dsl": "นําเข้า / ส่งออก DSL",
|
||||
"app.acl.monitor": "การตรวจสอบและการดําเนินการ",
|
||||
"app.acl.monitor": "เข้าถึงหน้าการตรวจสอบ",
|
||||
"app.acl.tracing_config": "กำหนดค่าการติดตามภายนอก",
|
||||
"app.acl.log_and_annotation": "เข้าถึงบันทึกและคำอธิบายประกอบ",
|
||||
"app.acl.preview": "ดูตัวอย่างแอป",
|
||||
"app.acl.release_and_version": "การเผยแพร่แอปและการจัดการเวอร์ชัน",
|
||||
"app.acl.test_and_run": "ทดสอบและใช้งานแอป",
|
||||
|
||||
@ -256,7 +256,7 @@
|
||||
"members.removeFromTeam": "Takımdan Kaldır",
|
||||
"members.removeFromTeamConfirmDescription": "Bu üyeyi kaldırmayı onaylayın. Bu işlem geri alınamaz.",
|
||||
"members.removeFromTeamConfirmTitle": "{{memberName}} adlı üyeyi takımdan kaldır",
|
||||
"members.role": "ROLLER",
|
||||
"members.role": "ROL",
|
||||
"members.roles": "ROLLER",
|
||||
"members.selectRole": "Bir rol seçin",
|
||||
"members.sendInvite": "Davet Gönder",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "Uygulamayı sil",
|
||||
"app.acl.edit": "Uygulamayı düzenle ve düzenle",
|
||||
"app.acl.import_export_dsl": "DSL içe / dışa aktar",
|
||||
"app.acl.monitor": "İzleme ve operasyonlar",
|
||||
"app.acl.monitor": "İzleme sayfasına eriş",
|
||||
"app.acl.tracing_config": "Harici izlemeyi yapılandır",
|
||||
"app.acl.log_and_annotation": "Günlüklere ve açıklamalara eriş",
|
||||
"app.acl.preview": "Uygulamayı önizle",
|
||||
"app.acl.release_and_version": "Uygulama yayınlama ve sürüm yönetimi",
|
||||
"app.acl.test_and_run": "Uygulamayı test et ve kullan",
|
||||
|
||||
@ -256,7 +256,7 @@
|
||||
"members.removeFromTeam": "Видалити з команди",
|
||||
"members.removeFromTeamConfirmDescription": "Підтвердьте видалення цього учасника. Цю дію не можна скасувати.",
|
||||
"members.removeFromTeamConfirmTitle": "Видалити {{memberName}} з команди",
|
||||
"members.role": "РОЛІ",
|
||||
"members.role": "РОЛЬ",
|
||||
"members.roles": "РОЛІ",
|
||||
"members.selectRole": "Виберіть роль",
|
||||
"members.sendInvite": "Надіслати запрошення",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "Видалити застосунок",
|
||||
"app.acl.edit": "Редагувати та оркеструвати застосунок",
|
||||
"app.acl.import_export_dsl": "Імпорт / експорт DSL",
|
||||
"app.acl.monitor": "Моніторинг та операції",
|
||||
"app.acl.monitor": "Доступ до сторінки моніторингу",
|
||||
"app.acl.tracing_config": "Налаштування зовнішнього трасування",
|
||||
"app.acl.log_and_annotation": "Доступ до журналів і анотацій",
|
||||
"app.acl.preview": "Попередній перегляд застосунку",
|
||||
"app.acl.release_and_version": "Публікація застосунку та керування версіями",
|
||||
"app.acl.test_and_run": "Тестувати та використовувати застосунок",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "Xóa ứng dụng",
|
||||
"app.acl.edit": "Chỉnh sửa và điều phối ứng dụng",
|
||||
"app.acl.import_export_dsl": "Nhập / xuất DSL",
|
||||
"app.acl.monitor": "Giám sát và vận hành",
|
||||
"app.acl.monitor": "Truy cập trang giám sát",
|
||||
"app.acl.tracing_config": "Cấu hình tracing bên ngoài",
|
||||
"app.acl.log_and_annotation": "Truy cập nhật ký và chú thích",
|
||||
"app.acl.preview": "Xem trước ứng dụng",
|
||||
"app.acl.release_and_version": "Phát hành ứng dụng và quản lý phiên bản",
|
||||
"app.acl.test_and_run": "Kiểm thử và sử dụng ứng dụng",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "删除应用",
|
||||
"app.acl.edit": "编辑与编排应用",
|
||||
"app.acl.import_export_dsl": "导入 / 导出 DSL",
|
||||
"app.acl.monitor": "监测与运维",
|
||||
"app.acl.monitor": "访问监测页面",
|
||||
"app.acl.tracing_config": "配置外部追踪",
|
||||
"app.acl.log_and_annotation": "访问日志与标注",
|
||||
"app.acl.preview": "预览应用",
|
||||
"app.acl.release_and_version": "应用发布与版本管理",
|
||||
"app.acl.test_and_run": "测试与使用应用",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"app.acl.delete": "刪除應用",
|
||||
"app.acl.edit": "編輯與編排應用",
|
||||
"app.acl.import_export_dsl": "匯入 / 匯出 DSL",
|
||||
"app.acl.monitor": "監測與維運",
|
||||
"app.acl.monitor": "訪問監測頁面",
|
||||
"app.acl.tracing_config": "配置外部追蹤",
|
||||
"app.acl.log_and_annotation": "訪問日誌與標註",
|
||||
"app.acl.preview": "預覽應用",
|
||||
"app.acl.release_and_version": "應用發布與版本管理",
|
||||
"app.acl.test_and_run": "測試與使用應用",
|
||||
|
||||
@ -85,6 +85,12 @@ describe('app-redirection', () => {
|
||||
|
||||
expect(getRedirectionPath(app)).toBe('/app/app-123/overview')
|
||||
})
|
||||
|
||||
it('returns logs path when app ACL can only access logs and annotations', () => {
|
||||
const app = { id: 'app-123', mode: AppModeEnum.CHAT, permission_keys: [AppACLPermission.LogAndAnnotation] }
|
||||
|
||||
expect(getRedirectionPath(app)).toBe('/app/app-123/logs')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@ -24,6 +24,9 @@ export const getRedirectionPath = (
|
||||
if (appACLCapabilities.canMonitor)
|
||||
return `/app/${app.id}/overview`
|
||||
|
||||
if (appACLCapabilities.canAccessLogAndAnnotation)
|
||||
return `/app/${app.id}/logs`
|
||||
|
||||
if (appACLCapabilities.canAccessConfig)
|
||||
return `/app/${app.id}/access-config`
|
||||
|
||||
|
||||
@ -42,6 +42,24 @@ describe('permission', () => {
|
||||
expect(capabilities.canComment).toBe(true)
|
||||
expect(capabilities.canTestAndRun).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps monitor, tracing config, and log/annotation permissions independent', () => {
|
||||
const monitorCapabilities = getAppACLCapabilities([AppACLPermission.Monitor])
|
||||
const tracingCapabilities = getAppACLCapabilities([AppACLPermission.TracingConfig])
|
||||
const logAndAnnotationCapabilities = getAppACLCapabilities([AppACLPermission.LogAndAnnotation])
|
||||
|
||||
expect(monitorCapabilities.canMonitor).toBe(true)
|
||||
expect(monitorCapabilities.canConfigureTracing).toBe(false)
|
||||
expect(monitorCapabilities.canAccessLogAndAnnotation).toBe(false)
|
||||
|
||||
expect(tracingCapabilities.canMonitor).toBe(false)
|
||||
expect(tracingCapabilities.canConfigureTracing).toBe(true)
|
||||
expect(tracingCapabilities.canAccessLogAndAnnotation).toBe(false)
|
||||
|
||||
expect(logAndAnnotationCapabilities.canMonitor).toBe(false)
|
||||
expect(logAndAnnotationCapabilities.canConfigureTracing).toBe(false)
|
||||
expect(logAndAnnotationCapabilities.canAccessLogAndAnnotation).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasOnlyAppPreviewPermission', () => {
|
||||
@ -87,6 +105,8 @@ describe('permission', () => {
|
||||
expect(capabilities.canDelete).toBe(true)
|
||||
expect(capabilities.canReleaseAndVersion).toBe(true)
|
||||
expect(capabilities.canMonitor).toBe(true)
|
||||
expect(capabilities.canConfigureTracing).toBe(true)
|
||||
expect(capabilities.canAccessLogAndAnnotation).toBe(true)
|
||||
expect(capabilities.canAccessConfig).toBe(true)
|
||||
expect(permissionKeys).toEqual([])
|
||||
})
|
||||
|
||||
@ -9,6 +9,8 @@ export const AppACLPermission = {
|
||||
Delete: 'app.acl.delete',
|
||||
ReleaseAndVersion: 'app.acl.release_and_version',
|
||||
Monitor: 'app.acl.monitor',
|
||||
TracingConfig: 'app.acl.tracing_config',
|
||||
LogAndAnnotation: 'app.acl.log_and_annotation',
|
||||
AccessConfig: 'app.acl.access_config',
|
||||
} as const
|
||||
|
||||
@ -51,6 +53,8 @@ type AppACLCapabilities = {
|
||||
canDelete: boolean
|
||||
canReleaseAndVersion: boolean
|
||||
canMonitor: boolean
|
||||
canConfigureTracing: boolean
|
||||
canAccessLogAndAnnotation: boolean
|
||||
canAccessConfig: boolean
|
||||
}
|
||||
|
||||
@ -124,6 +128,8 @@ export const getAppACLCapabilities = (
|
||||
canDelete: hasResourcePermission(permissionKeys, AppACLPermission.Delete, hasMaintainerPermissions),
|
||||
canReleaseAndVersion: hasResourcePermission(permissionKeys, AppACLPermission.ReleaseAndVersion, hasMaintainerPermissions),
|
||||
canMonitor: hasResourcePermission(permissionKeys, AppACLPermission.Monitor, hasMaintainerPermissions),
|
||||
canConfigureTracing: hasResourcePermission(permissionKeys, AppACLPermission.TracingConfig, hasMaintainerPermissions),
|
||||
canAccessLogAndAnnotation: hasResourcePermission(permissionKeys, AppACLPermission.LogAndAnnotation, hasMaintainerPermissions),
|
||||
canAccessConfig: Boolean(options?.isRbacEnabled) && hasResourcePermission(permissionKeys, AppACLPermission.AccessConfig, hasMaintainerPermissions),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user