Compare commits

..

5 Commits

Author SHA1 Message Date
ce5a809c71 feat: enhance app permissions and access controls 2026-06-25 16:43:46 +08:00
b33e8f0ddb fix: improve error handling for workflow execution (#37919) 2026-06-25 07:36:34 +00:00
8f74e176ca fix: remove redundant snippets permissions (#37921)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-25 07:27:16 +00:00
b9bcf31c72 chore(i18n): sync translations with en-US (#37916)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-06-25 07:07:23 +00:00
abf2986299 build(deps): update Bleach sanitizer security fix (#37860) 2026-06-25 06:32:48 +00:00
67 changed files with 1115 additions and 155 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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)

View File

@ -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 == []

View File

@ -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
View File

@ -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" },

View File

@ -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(

View File

@ -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)
) {

View File

@ -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()
})
})
})

View File

@ -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,

View File

@ -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, {

View File

@ -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>

View File

@ -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} />)

View File

@ -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`,

View File

@ -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) => {

View File

@ -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', () => {

View File

@ -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>
)
}

View File

@ -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,

View File

@ -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[]

View File

@ -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',
]

View File

@ -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', () => {

View File

@ -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
}

View File

@ -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', () => {

View File

@ -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', () => {

View File

@ -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,

View File

@ -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": "إرسال دعوة",

View File

@ -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": "اختبار التطبيق واستخدامه",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -256,7 +256,7 @@
"members.removeFromTeam": "حذف از تیم",
"members.removeFromTeamConfirmDescription": "حذف این عضو را تأیید کنید. این عمل قابل بازگشت نیست.",
"members.removeFromTeamConfirmTitle": "حذف {{memberName}} از تیم",
"members.role": "نقشها",
"members.role": "نقش",
"members.roles": "نقش‌ها",
"members.selectRole": "یک نقش انتخاب کنید",
"members.sendInvite": "ارسال دعوت",

View File

@ -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": "آزمایش و استفاده از برنامه",

View File

@ -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",

View File

@ -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",

View File

@ -256,7 +256,7 @@
"members.removeFromTeam": "टीम से हटाएं",
"members.removeFromTeamConfirmDescription": "इस सदस्य को हटाने की पुष्टि करें। यह कार्रवाई वापस नहीं की जा सकती।",
"members.removeFromTeamConfirmTitle": "{{memberName}} को टीम से हटाएं",
"members.role": "भूमिकाएं",
"members.role": "भूमिका",
"members.roles": "भूमिकाएं",
"members.selectRole": "एक भूमिका चुनें",
"members.sendInvite": "आमंत्रण भेजें",

View File

@ -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": "ऐप परीक्षण और उपयोग करें",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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": "アプリのテストと使用",

View File

@ -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": "앱 테스트 및 사용",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -256,7 +256,7 @@
"members.removeFromTeam": "Удалить из команды",
"members.removeFromTeamConfirmDescription": "Подтвердите удаление этого участника. Это действие нельзя отменить.",
"members.removeFromTeamConfirmTitle": "Удалить {{memberName}} из команды",
"members.role": "РОЛИ",
"members.role": "РОЛЬ",
"members.roles": "РОЛИ",
"members.selectRole": "Выберите роль",
"members.sendInvite": "Отправить приглашение",

View File

@ -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": "Тестирование и использование приложения",

View File

@ -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",

View File

@ -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",

View File

@ -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": "ทดสอบและใช้งานแอป",

View File

@ -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",

View File

@ -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",

View File

@ -256,7 +256,7 @@
"members.removeFromTeam": "Видалити з команди",
"members.removeFromTeamConfirmDescription": "Підтвердьте видалення цього учасника. Цю дію не можна скасувати.",
"members.removeFromTeamConfirmTitle": "Видалити {{memberName}} з команди",
"members.role": "РОЛІ",
"members.role": "РОЛЬ",
"members.roles": "РОЛІ",
"members.selectRole": "Виберіть роль",
"members.sendInvite": "Надіслати запрошення",

View File

@ -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": "Тестувати та використовувати застосунок",

View File

@ -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",

View File

@ -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": "测试与使用应用",

View File

@ -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": "測試與使用應用",

View File

@ -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')
})
})
/**

View File

@ -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`

View File

@ -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([])
})

View File

@ -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),
}
}