From 71cf2b322045892876d7ef254ce2eea96bf6ba64 Mon Sep 17 00:00:00 2001
From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com>
Date: Fri, 17 Jul 2026 13:20:50 +0800
Subject: [PATCH 1/9] fix(webui): sanitize automation action errors
---
.../ironclaw_webui_v2/frontend/src/i18n/ar.ts | 1 +
.../ironclaw_webui_v2/frontend/src/i18n/de.ts | 1 +
.../ironclaw_webui_v2/frontend/src/i18n/en.ts | 1 +
.../ironclaw_webui_v2/frontend/src/i18n/es.ts | 1 +
.../ironclaw_webui_v2/frontend/src/i18n/fr.ts | 1 +
.../ironclaw_webui_v2/frontend/src/i18n/hi.ts | 1 +
.../ironclaw_webui_v2/frontend/src/i18n/ja.ts | 1 +
.../ironclaw_webui_v2/frontend/src/i18n/ko.ts | 1 +
.../frontend/src/i18n/pt-BR.ts | 1 +
.../ironclaw_webui_v2/frontend/src/i18n/uk.ts | 1 +
.../frontend/src/i18n/zh-CN.ts | 1 +
.../pages/automations/automations-page.tsx | 17 +++++++--
.../pages/automations/hooks/useAutomations.ts | 35 +++++++++++++------
13 files changed, 51 insertions(+), 12 deletions(-)
diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/ar.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/ar.ts
index 7e5a44a25cc..63794539ee4 100644
--- a/crates/ironclaw_webui_v2/frontend/src/i18n/ar.ts
+++ b/crates/ironclaw_webui_v2/frontend/src/i18n/ar.ts
@@ -739,6 +739,7 @@ registerPack("ar", {
"automations.filter.completed": "مكتمل",
"automations.refresh": "تحديث عمليات الأتمتة",
"automations.error.loadFailed": "غير قادر على تحميل عمليات الأتمتة",
+ "automations.error.actionFailed": "تعذّر تحديث الأتمتة. يُرجى المحاولة مرة أخرى.",
"automations.rename.action": "إعادة تسمية الأتمتة",
"automations.rename.nameLabel": "اسم الأتمتة",
"automations.rename.nameRequired": "اسم الأتمتة مطلوب.",
diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/de.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/de.ts
index 62d0a248760..e2b53bcf59b 100644
--- a/crates/ironclaw_webui_v2/frontend/src/i18n/de.ts
+++ b/crates/ironclaw_webui_v2/frontend/src/i18n/de.ts
@@ -739,6 +739,7 @@ registerPack("de", {
"automations.filter.completed": "Abgeschlossen",
"automations.refresh": "Automatisierungen aktualisieren",
"automations.error.loadFailed": "Automatisierungen können nicht geladen werden",
+ "automations.error.actionFailed": "Die Automatisierung konnte nicht aktualisiert werden. Versuche es erneut.",
"automations.rename.action": "Automatisierung umbenennen",
"automations.rename.nameLabel": "Automatisierungsname",
"automations.rename.nameRequired": "Automatisierungsname ist erforderlich.",
diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/en.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/en.ts
index 2f89f28ac5a..1425519f4ea 100644
--- a/crates/ironclaw_webui_v2/frontend/src/i18n/en.ts
+++ b/crates/ironclaw_webui_v2/frontend/src/i18n/en.ts
@@ -786,6 +786,7 @@ registerPack("en", {
"automations.filter.completed": "Completed",
"automations.refresh": "Refresh automations",
"automations.error.loadFailed": "Unable to load automations",
+ "automations.error.actionFailed": "Unable to update the automation. Please try again.",
"automations.rename.action": "Rename automation",
"automations.rename.nameLabel": "Automation name",
"automations.rename.nameRequired": "Automation name is required.",
diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/es.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/es.ts
index 81fed4dfee7..cf10e4b90bd 100644
--- a/crates/ironclaw_webui_v2/frontend/src/i18n/es.ts
+++ b/crates/ironclaw_webui_v2/frontend/src/i18n/es.ts
@@ -739,6 +739,7 @@ registerPack("es", {
"automations.filter.completed": "Completadas",
"automations.refresh": "Actualizar automatizaciones",
"automations.error.loadFailed": "No se pueden cargar automatizaciones",
+ "automations.error.actionFailed": "No se pudo actualizar la automatización. Inténtalo de nuevo.",
"automations.rename.action": "Cambiar nombre de automatización",
"automations.rename.nameLabel": "Nombre de automatización",
"automations.rename.nameRequired": "El nombre de la automatización es obligatorio.",
diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/fr.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/fr.ts
index 3153f39dbeb..0b7f75152d1 100644
--- a/crates/ironclaw_webui_v2/frontend/src/i18n/fr.ts
+++ b/crates/ironclaw_webui_v2/frontend/src/i18n/fr.ts
@@ -739,6 +739,7 @@ registerPack("fr", {
"automations.filter.completed": "Terminées",
"automations.refresh": "Actualiser les automatisations",
"automations.error.loadFailed": "Impossible de charger les automatisations",
+ "automations.error.actionFailed": "Impossible de mettre à jour l’automatisation. Réessayez.",
"automations.rename.action": "Renommer l’automatisation",
"automations.rename.nameLabel": "Nom de l’automatisation",
"automations.rename.nameRequired": "Le nom de l’automatisation est requis.",
diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/hi.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/hi.ts
index cd2dd4d8539..8ff05c0f81b 100644
--- a/crates/ironclaw_webui_v2/frontend/src/i18n/hi.ts
+++ b/crates/ironclaw_webui_v2/frontend/src/i18n/hi.ts
@@ -739,6 +739,7 @@ registerPack("hi", {
"automations.filter.completed": "पूर्ण",
"automations.refresh": "ऑटोमेशन ताज़ा करें",
"automations.error.loadFailed": "ऑटोमेशन लोड करने में असमर्थ",
+ "automations.error.actionFailed": "ऑटोमेशन अपडेट नहीं किया जा सका। फिर से कोशिश करें।",
"automations.rename.action": "ऑटोमेशन का नाम बदलें",
"automations.rename.nameLabel": "ऑटोमेशन नाम",
"automations.rename.nameRequired": "ऑटोमेशन नाम आवश्यक है.",
diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/ja.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/ja.ts
index cdc117e9437..be92ccb313c 100644
--- a/crates/ironclaw_webui_v2/frontend/src/i18n/ja.ts
+++ b/crates/ironclaw_webui_v2/frontend/src/i18n/ja.ts
@@ -739,6 +739,7 @@ registerPack("ja", {
"automations.filter.completed": "完了",
"automations.refresh": "自動化の更新",
"automations.error.loadFailed": "自動化をロードできません",
+ "automations.error.actionFailed": "自動化を更新できませんでした。もう一度お試しください。",
"automations.rename.action": "自動化名を変更",
"automations.rename.nameLabel": "自動化名",
"automations.rename.nameRequired": "自動化名は必須です。",
diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/ko.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/ko.ts
index 222b7f255fc..ab3d81173d6 100644
--- a/crates/ironclaw_webui_v2/frontend/src/i18n/ko.ts
+++ b/crates/ironclaw_webui_v2/frontend/src/i18n/ko.ts
@@ -739,6 +739,7 @@ registerPack("ko", {
"automations.filter.completed": "완료됨",
"automations.refresh": "자동화 새로 고침",
"automations.error.loadFailed": "자동화를 로드할 수 없음",
+ "automations.error.actionFailed": "자동화를 업데이트할 수 없습니다. 다시 시도하세요.",
"automations.rename.action": "자동화 이름 변경",
"automations.rename.nameLabel": "자동화 이름",
"automations.rename.nameRequired": "자동화 이름은 필수입니다.",
diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/pt-BR.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/pt-BR.ts
index 45bb44f18a8..06f713aca8b 100644
--- a/crates/ironclaw_webui_v2/frontend/src/i18n/pt-BR.ts
+++ b/crates/ironclaw_webui_v2/frontend/src/i18n/pt-BR.ts
@@ -739,6 +739,7 @@ registerPack("pt-BR", {
"automations.filter.completed": "Concluídas",
"automations.refresh": "Atualizar automações",
"automations.error.loadFailed": "Não é possível carregar automações",
+ "automations.error.actionFailed": "Não foi possível atualizar a automação. Tente novamente.",
"automations.rename.action": "Renomear automação",
"automations.rename.nameLabel": "Nome da automação",
"automations.rename.nameRequired": "O nome da automação é obrigatório.",
diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/uk.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/uk.ts
index 4fff8f41468..78a19c36efd 100644
--- a/crates/ironclaw_webui_v2/frontend/src/i18n/uk.ts
+++ b/crates/ironclaw_webui_v2/frontend/src/i18n/uk.ts
@@ -739,6 +739,7 @@ registerPack("uk", {
"automations.filter.completed": "Завершені",
"automations.refresh": "Оновити автоматизації",
"automations.error.loadFailed": "Неможливо завантажити автоматизації",
+ "automations.error.actionFailed": "Не вдалося оновити автоматизацію. Спробуйте ще раз.",
"automations.rename.action": "Перейменувати автоматизацію",
"automations.rename.nameLabel": "Назва автоматизації",
"automations.rename.nameRequired": "Назва автоматизації обов'язкова.",
diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/zh-CN.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/zh-CN.ts
index d185fdf75d3..5e6a4362a70 100644
--- a/crates/ironclaw_webui_v2/frontend/src/i18n/zh-CN.ts
+++ b/crates/ironclaw_webui_v2/frontend/src/i18n/zh-CN.ts
@@ -739,6 +739,7 @@ registerPack("zh-CN", {
"automations.filter.completed": "已完成",
"automations.refresh": "更新最新自动化",
"automations.error.loadFailed": "提取系统自动化计划列表不成功!",
+ "automations.error.actionFailed": "无法更新自动化。请重试。",
"automations.rename.action": "重命名自动化",
"automations.rename.nameLabel": "自动化名称",
"automations.rename.nameRequired": "自动化名称不能为空。",
diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/automations/automations-page.tsx b/crates/ironclaw_webui_v2/frontend/src/pages/automations/automations-page.tsx
index 1e107e4a6e4..0c2e75dc359 100644
--- a/crates/ironclaw_webui_v2/frontend/src/pages/automations/automations-page.tsx
+++ b/crates/ironclaw_webui_v2/frontend/src/pages/automations/automations-page.tsx
@@ -1,4 +1,5 @@
import React from "react";
+import { Icon } from "../../design-system/icons";
import { useT } from "../../lib/i18n";
import { AutomationDeliveryDefaultsPanel } from "./components/automation-delivery-defaults-panel";
import { AutomationsList } from "./components/automations-list";
@@ -60,9 +61,21 @@ export function AutomationsPage() {
{automationsState.actionError &&
(
- {automationsState.actionError.message}
+
+ {t("automations.error.actionFailed")}
+
+
)}
diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts
index d611933e30f..38077fb70cf 100644
--- a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts
+++ b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts
@@ -29,6 +29,7 @@ type RenameAutomationVariables = {
export function useAutomations(includeCompleted = false) {
const { t, lang } = useI18n();
const queryClient = useQueryClient();
+ const [hasActionError, setHasActionError] = React.useState(false);
const query = useQuery({
queryKey: ["automations", { includeCompleted }],
queryFn: () =>
@@ -73,22 +74,40 @@ export function useAutomations(includeCompleted = false) {
const invalidateAutomations = React.useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["automations"] });
}, [queryClient]);
+ const clearActionError = React.useCallback(() => {
+ setHasActionError(false);
+ }, []);
+ const showActionError = React.useCallback(() => {
+ setHasActionError(true);
+ }, []);
+ const handleActionSuccess = React.useCallback(() => {
+ clearActionError();
+ invalidateAutomations();
+ }, [clearActionError, invalidateAutomations]);
const pauseMutation = useMutation({
mutationFn: (automationId: string) => pauseAutomation({ automationId }),
- onSuccess: invalidateAutomations,
+ onMutate: clearActionError,
+ onError: showActionError,
+ onSuccess: handleActionSuccess,
});
const resumeMutation = useMutation({
mutationFn: (automationId: string) => resumeAutomation({ automationId }),
- onSuccess: invalidateAutomations,
+ onMutate: clearActionError,
+ onError: showActionError,
+ onSuccess: handleActionSuccess,
});
const renameMutation = useMutation({
mutationFn: ({ automationId, name }: RenameAutomationVariables) =>
renameAutomation({ automationId, name }),
- onSuccess: invalidateAutomations,
+ onMutate: clearActionError,
+ onError: showActionError,
+ onSuccess: handleActionSuccess,
});
const deleteMutation = useMutation({
mutationFn: (automationId: string) => deleteAutomation({ automationId }),
- onSuccess: invalidateAutomations,
+ onMutate: clearActionError,
+ onError: showActionError,
+ onSuccess: handleActionSuccess,
});
return {
@@ -103,12 +122,8 @@ export function useAutomations(includeCompleted = false) {
renameMutation.isPending ||
deleteMutation.isPending,
error: query.error || null,
- actionError:
- pauseMutation.error ||
- resumeMutation.error ||
- renameMutation.error ||
- deleteMutation.error ||
- null,
+ actionError: hasActionError,
+ dismissActionError: clearActionError,
pauseAutomation: pauseMutation.mutate,
resumeAutomation: resumeMutation.mutate,
renameAutomation: renameMutation.mutate,
From e4e99755340b1e1c6a8b8a93f312b464472d06b3 Mon Sep 17 00:00:00 2001
From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com>
Date: Fri, 17 Jul 2026 13:24:15 +0800
Subject: [PATCH 2/9] test(e2e): cover automation action error recovery
---
tests/e2e/helpers.py | 1 +
.../scenarios/test_reborn_webui_v2_smoke.py | 103 ++++++++++++++++++
2 files changed, 104 insertions(+)
diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py
index 1201514af51..9537e5e4ce8 100644
--- a/tests/e2e/helpers.py
+++ b/tests/e2e/helpers.py
@@ -370,6 +370,7 @@
"automation_rename_button": "[data-testid='automation-rename-button']",
"automation_rename_input": "[data-testid='automation-rename-input']",
"automation_rename_save": "[data-testid='automation-rename-save']",
+ "automation_action_error": "[data-testid='automation-action-error']",
"automation_run_open": "[data-testid='automation-run-open']",
"automation_run_logs": "[data-testid='automation-run-logs']",
"skills_card": "#skills-list .ext-card",
diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
index 8469aa5b98e..1c96bc39035 100644
--- a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
+++ b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
@@ -424,6 +424,109 @@ async def test_reborn_v2_automation_rename_persists_from_ui(
assert renamed["automation_id"] == automation_id
+async def test_reborn_v2_automation_action_error_is_safe_dismissible_and_cleared_on_retry(
+ reborn_v2_server, reborn_v2_browser
+):
+ """Automation mutations never expose raw errors and clear stale failures."""
+ automation_id = "11111111-2222-3333-4444-555555555555"
+ raw_error = "postgres failed: secret_internal_automation_table"
+ attempt_count = 0
+ retry_started = asyncio.Event()
+ release_retry = asyncio.Event()
+ retry_completed = asyncio.Event()
+
+ context = await reborn_v2_browser.new_context(viewport={"width": 1280, "height": 720})
+ page = await context.new_page()
+
+ async def handle_automations(route) -> None:
+ nonlocal attempt_count
+ if route.request.method == "GET":
+ await route.fulfill(
+ status=200,
+ content_type="application/json",
+ body=json.dumps(
+ {
+ "scheduler_enabled": True,
+ "automations": [
+ {
+ "automation_id": automation_id,
+ "name": "Safe action error regression",
+ "source": {
+ "type": "schedule",
+ "cron": "0 9 * * *",
+ "timezone": "UTC",
+ },
+ "state": "active",
+ "next_run_at": "2026-07-18T09:00:00Z",
+ "recent_runs": [],
+ }
+ ],
+ }
+ ),
+ )
+ return
+
+ attempt_count += 1
+ if attempt_count <= 2:
+ await route.fulfill(
+ status=500,
+ content_type="text/plain",
+ body=raw_error,
+ )
+ return
+
+ retry_started.set()
+ await release_retry.wait()
+ await route.fulfill(
+ status=200,
+ content_type="application/json",
+ body=json.dumps({"updated": True}),
+ )
+ retry_completed.set()
+
+ await page.route("**/api/webchat/v2/automations**", handle_automations)
+ row_selector = SEL_V2["automation_row_for"].format(id=automation_id)
+ error_banner = page.locator(SEL_V2["automation_action_error"])
+
+ async def submit_rename(name: str) -> None:
+ row = page.locator(row_selector)
+ await expect(row).to_be_visible(timeout=15000)
+ await row.locator(
+ SEL_V2["automation_name_button_for"].format(id=automation_id)
+ ).click()
+ await page.locator(SEL_V2["automation_rename_button"]).click()
+ rename_input = page.locator(SEL_V2["automation_rename_input"])
+ await rename_input.fill(name)
+ await page.locator(SEL_V2["automation_rename_save"]).click()
+
+ try:
+ await page.goto(f"{reborn_v2_server}/v2/automations?token={REBORN_V2_AUTH_TOKEN}")
+
+ await submit_rename("First failed rename")
+ await expect(error_banner).to_be_visible(timeout=10000)
+ await expect(error_banner).to_have_text(
+ "Unable to update the automation. Please try again."
+ )
+ await expect(error_banner).not_to_contain_text(raw_error)
+
+ await error_banner.get_by_role("button", name="Dismiss").click()
+ await expect(error_banner).to_have_count(0)
+
+ await submit_rename("Second failed rename")
+ await expect(error_banner).to_be_visible(timeout=10000)
+
+ await submit_rename("Successful retry")
+ await asyncio.wait_for(retry_started.wait(), timeout=10)
+ await expect(error_banner).to_have_count(0)
+
+ release_retry.set()
+ await asyncio.wait_for(retry_completed.wait(), timeout=10)
+ await expect(error_banner).to_have_count(0)
+ finally:
+ release_retry.set()
+ await context.close()
+
+
async def test_reborn_v2_automation_failed_run_actions_are_clickable(
reborn_v2_server, reborn_v2_browser
):
From 534e5d3741ddc24e5948eeaf71483cb6d6dbca50 Mon Sep 17 00:00:00 2001
From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com>
Date: Fri, 17 Jul 2026 13:40:33 +0800
Subject: [PATCH 3/9] fix(webui): log automation action errors
---
.../src/pages/automations/hooks/useAutomations.ts | 3 ++-
tests/e2e/scenarios/test_reborn_webui_v2_smoke.py | 8 ++++++++
2 files changed, 10 insertions(+), 1 deletion(-)
diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts
index 38077fb70cf..54cc53126a9 100644
--- a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts
+++ b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts
@@ -77,7 +77,8 @@ export function useAutomations(includeCompleted = false) {
const clearActionError = React.useCallback(() => {
setHasActionError(false);
}, []);
- const showActionError = React.useCallback(() => {
+ const showActionError = React.useCallback((error: unknown) => {
+ console.error("Automation action failed:", error);
setHasActionError(true);
}, []);
const handleActionSuccess = React.useCallback(() => {
diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
index 1c96bc39035..de498829b1b 100644
--- a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
+++ b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
@@ -434,10 +434,17 @@ async def test_reborn_v2_automation_action_error_is_safe_dismissible_and_cleared
retry_started = asyncio.Event()
release_retry = asyncio.Event()
retry_completed = asyncio.Event()
+ raw_error_logged = asyncio.Event()
context = await reborn_v2_browser.new_context(viewport={"width": 1280, "height": 720})
page = await context.new_page()
+ def capture_console_error(message) -> None:
+ if message.type == "error" and raw_error in message.text:
+ raw_error_logged.set()
+
+ page.on("console", capture_console_error)
+
async def handle_automations(route) -> None:
nonlocal attempt_count
if route.request.method == "GET":
@@ -508,6 +515,7 @@ async def submit_rename(name: str) -> None:
"Unable to update the automation. Please try again."
)
await expect(error_banner).not_to_contain_text(raw_error)
+ await asyncio.wait_for(raw_error_logged.wait(), timeout=10)
await error_banner.get_by_role("button", name="Dismiss").click()
await expect(error_banner).to_have_count(0)
From fd21497888ab4398ff392c0c97e648f960f3c719 Mon Sep 17 00:00:00 2001
From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com>
Date: Fri, 17 Jul 2026 16:32:01 +0800
Subject: [PATCH 4/9] fix(webui): secure automation action error state
---
.../pages/automations/hooks/useAutomations.ts | 49 +++++++++++++++----
1 file changed, 40 insertions(+), 9 deletions(-)
diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts
index 54cc53126a9..fc6bd2daf07 100644
--- a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts
+++ b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts
@@ -26,10 +26,22 @@ type RenameAutomationVariables = {
name: string;
};
+type ActionMutationContext = {
+ sequence: number;
+};
+
+function isLatestAutomationAction(
+ context: ActionMutationContext | undefined,
+ latestSequence: number
+) {
+ return context?.sequence === latestSequence;
+}
+
export function useAutomations(includeCompleted = false) {
const { t, lang } = useI18n();
const queryClient = useQueryClient();
const [hasActionError, setHasActionError] = React.useState(false);
+ const latestActionSequence = React.useRef(0);
const query = useQuery({
queryKey: ["automations", { includeCompleted }],
queryFn: () =>
@@ -77,36 +89,55 @@ export function useAutomations(includeCompleted = false) {
const clearActionError = React.useCallback(() => {
setHasActionError(false);
}, []);
- const showActionError = React.useCallback((error: unknown) => {
- console.error("Automation action failed:", error);
- setHasActionError(true);
- }, []);
- const handleActionSuccess = React.useCallback(() => {
+ const beginAction = React.useCallback((): ActionMutationContext => {
+ const sequence = latestActionSequence.current + 1;
+ latestActionSequence.current = sequence;
clearActionError();
+ return { sequence };
+ }, [clearActionError]);
+ const showActionError = React.useCallback((
+ _error: unknown,
+ _variables: unknown,
+ context: ActionMutationContext | undefined
+ ) => {
+ // A newer action deliberately supersedes older results: starting any
+ // action clears the banner, and a late completion must not resurrect it.
+ if (isLatestAutomationAction(context, latestActionSequence.current)) {
+ setHasActionError(true);
+ }
+ }, []);
+ const handleActionSuccess = React.useCallback((
+ _data: unknown,
+ _variables: unknown,
+ context: ActionMutationContext | undefined
+ ) => {
+ if (isLatestAutomationAction(context, latestActionSequence.current)) {
+ clearActionError();
+ }
invalidateAutomations();
}, [clearActionError, invalidateAutomations]);
const pauseMutation = useMutation({
mutationFn: (automationId: string) => pauseAutomation({ automationId }),
- onMutate: clearActionError,
+ onMutate: beginAction,
onError: showActionError,
onSuccess: handleActionSuccess,
});
const resumeMutation = useMutation({
mutationFn: (automationId: string) => resumeAutomation({ automationId }),
- onMutate: clearActionError,
+ onMutate: beginAction,
onError: showActionError,
onSuccess: handleActionSuccess,
});
const renameMutation = useMutation({
mutationFn: ({ automationId, name }: RenameAutomationVariables) =>
renameAutomation({ automationId, name }),
- onMutate: clearActionError,
+ onMutate: beginAction,
onError: showActionError,
onSuccess: handleActionSuccess,
});
const deleteMutation = useMutation({
mutationFn: (automationId: string) => deleteAutomation({ automationId }),
- onMutate: clearActionError,
+ onMutate: beginAction,
onError: showActionError,
onSuccess: handleActionSuccess,
});
From e857d48ecc35fa62716337f5ae7c7b55e066ad19 Mon Sep 17 00:00:00 2001
From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com>
Date: Fri, 17 Jul 2026 16:32:51 +0800
Subject: [PATCH 5/9] test(webui): cover ordered automation action failures
---
.../automations/hooks/useAutomations.test.ts | 132 ++++++++++++++++++
.../scenarios/test_reborn_webui_v2_smoke.py | 43 ++++--
2 files changed, 163 insertions(+), 12 deletions(-)
create mode 100644 crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts
diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts
new file mode 100644
index 00000000000..cc195de4ccc
--- /dev/null
+++ b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts
@@ -0,0 +1,132 @@
+// @ts-nocheck
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import vm from "node:vm";
+import ts from "typescript";
+import { test } from "vitest";
+
+import { sourceTextForVmTest } from "../../../test-support/vm-module-harness";
+
+function loadUseAutomations() {
+ const source = readFileSync(new URL("./useAutomations.ts", import.meta.url), "utf8");
+ const transpiled = ts.transpileModule(source, {
+ compilerOptions: {
+ module: ts.ModuleKind.ESNext,
+ target: ts.ScriptTarget.ES2022,
+ },
+ }).outputText;
+ const mutationConfigs = [];
+ const stateUpdates = [];
+ const invalidations = [];
+ const consoleErrors = [];
+ const context = {
+ AUTOMATIONS_BASE_REFETCH_MS: 30000,
+ React: {
+ useCallback: (callback) => callback,
+ useEffect: () => {},
+ useMemo: (factory) => factory(),
+ useRef: (initial) => ({ current: initial }),
+ useState: (initial) => [initial, (value) => stateUpdates.push(value)],
+ },
+ automationSummary: () => ({}),
+ console: {
+ error: (...args) => consoleErrors.push(args),
+ },
+ deleteAutomation: () => {},
+ globalThis: {},
+ listAutomations: () => {},
+ nextAutomationsRefetchDelay: () => null,
+ normalizeAutomations: () => [],
+ pauseAutomation: () => {},
+ renameAutomation: () => {},
+ resumeAutomation: () => {},
+ setTimeout,
+ clearTimeout,
+ useI18n: () => ({ lang: "en", t: (key) => key }),
+ useMutation: (config) => {
+ mutationConfigs.push(config);
+ return { isPending: false, mutate: () => {} };
+ },
+ useQuery: () => ({
+ data: { automations: [], scheduler_enabled: true },
+ error: null,
+ isFetching: false,
+ isLoading: false,
+ refetch: () => {},
+ }),
+ useQueryClient: () => ({
+ invalidateQueries: (query) => invalidations.push(query),
+ }),
+ };
+
+ vm.runInNewContext(
+ sourceTextForVmTest(transpiled, ["useAutomations"]),
+ context
+ );
+
+ return {
+ consoleErrors,
+ invalidations,
+ mutationConfigs,
+ stateUpdates,
+ useAutomations: context.globalThis.__testExports.useAutomations,
+ };
+}
+
+test("automation action results only update the latest action state", () => {
+ const {
+ consoleErrors,
+ invalidations,
+ mutationConfigs,
+ stateUpdates,
+ useAutomations,
+ } = loadUseAutomations();
+ useAutomations();
+
+ assert.equal(mutationConfigs.length, 4);
+ for (const callbackName of ["onMutate", "onError", "onSuccess"]) {
+ assert.equal(
+ new Set(mutationConfigs.map((config) => config[callbackName])).size,
+ 1,
+ `all automation mutations must share ${callbackName}`
+ );
+ }
+
+ const firstAction = mutationConfigs[0].onMutate("automation-1");
+ const secondAction = mutationConfigs[2].onMutate({
+ automationId: "automation-2",
+ name: "New name",
+ });
+
+ mutationConfigs[0].onError(
+ new Error("raw backend detail"),
+ "automation-1",
+ firstAction
+ );
+ assert.deepEqual(stateUpdates, [false, false]);
+
+ mutationConfigs[2].onError(
+ new Error("raw backend detail"),
+ { automationId: "automation-2", name: "New name" },
+ secondAction
+ );
+ assert.deepEqual(stateUpdates, [false, false, true]);
+ assert.deepEqual(consoleErrors, []);
+
+ const thirdAction = mutationConfigs[1].onMutate("automation-3");
+ mutationConfigs[2].onSuccess(
+ { updated: true },
+ { automationId: "automation-2", name: "New name" },
+ secondAction
+ );
+ assert.deepEqual(stateUpdates, [false, false, true, false]);
+ assert.equal(invalidations.length, 1);
+
+ mutationConfigs[1].onSuccess(
+ { updated: true },
+ "automation-3",
+ thirdAction
+ );
+ assert.deepEqual(stateUpdates, [false, false, true, false, false]);
+ assert.equal(invalidations.length, 2);
+});
diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
index de498829b1b..179e9d4a1e5 100644
--- a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
+++ b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
@@ -427,23 +427,20 @@ async def test_reborn_v2_automation_rename_persists_from_ui(
async def test_reborn_v2_automation_action_error_is_safe_dismissible_and_cleared_on_retry(
reborn_v2_server, reborn_v2_browser
):
- """Automation mutations never expose raw errors and clear stale failures."""
+ """Automation mutations keep raw errors private and clear stale failures."""
automation_id = "11111111-2222-3333-4444-555555555555"
+ automation_name = "Safe action error regression"
raw_error = "postgres failed: secret_internal_automation_table"
attempt_count = 0
+ mutation_requests: list[tuple[str, str]] = []
+ console_messages: list[str] = []
retry_started = asyncio.Event()
release_retry = asyncio.Event()
retry_completed = asyncio.Event()
- raw_error_logged = asyncio.Event()
context = await reborn_v2_browser.new_context(viewport={"width": 1280, "height": 720})
page = await context.new_page()
-
- def capture_console_error(message) -> None:
- if message.type == "error" and raw_error in message.text:
- raw_error_logged.set()
-
- page.on("console", capture_console_error)
+ page.on("console", lambda message: console_messages.append(message.text))
async def handle_automations(route) -> None:
nonlocal attempt_count
@@ -457,7 +454,7 @@ async def handle_automations(route) -> None:
"automations": [
{
"automation_id": automation_id,
- "name": "Safe action error regression",
+ "name": automation_name,
"source": {
"type": "schedule",
"cron": "0 9 * * *",
@@ -473,6 +470,9 @@ async def handle_automations(route) -> None:
)
return
+ mutation_requests.append(
+ (route.request.method, urlparse(route.request.url).path)
+ )
attempt_count += 1
if attempt_count <= 2:
await route.fulfill(
@@ -515,21 +515,40 @@ async def submit_rename(name: str) -> None:
"Unable to update the automation. Please try again."
)
await expect(error_banner).not_to_contain_text(raw_error)
- await asyncio.wait_for(raw_error_logged.wait(), timeout=10)
+ assert not any(raw_error in message for message in console_messages)
await error_banner.get_by_role("button", name="Dismiss").click()
await expect(error_banner).to_have_count(0)
- await submit_rename("Second failed rename")
+ pause_button = page.get_by_role(
+ "button", name=f"Pause: {automation_name}", exact=True
+ )
+ await pause_button.click()
await expect(error_banner).to_be_visible(timeout=10000)
+ assert not any(raw_error in message for message in console_messages)
- await submit_rename("Successful retry")
+ await pause_button.click()
await asyncio.wait_for(retry_started.wait(), timeout=10)
await expect(error_banner).to_have_count(0)
release_retry.set()
await asyncio.wait_for(retry_completed.wait(), timeout=10)
await expect(error_banner).to_have_count(0)
+ assert not any(raw_error in message for message in console_messages)
+ assert mutation_requests == [
+ (
+ "POST",
+ f"/api/webchat/v2/automations/{automation_id}",
+ ),
+ (
+ "POST",
+ f"/api/webchat/v2/automations/{automation_id}/pause",
+ ),
+ (
+ "POST",
+ f"/api/webchat/v2/automations/{automation_id}/pause",
+ ),
+ ]
finally:
release_retry.set()
await context.close()
From 96e0abd872193224b2016047332d28be1b3e400a Mon Sep 17 00:00:00 2001
From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com>
Date: Fri, 17 Jul 2026 18:32:25 +0800
Subject: [PATCH 6/9] fix(webui): show automation action errors as toasts
---
.../frontend/src/lib/toast.test.ts | 10 ++++++
.../frontend/src/lib/toast.ts | 5 +++
.../pages/automations/automations-page.tsx | 22 ------------
.../automations/hooks/useAutomations.test.ts | 36 +++++++++++++------
.../pages/automations/hooks/useAutomations.ts | 17 +++++----
5 files changed, 51 insertions(+), 39 deletions(-)
diff --git a/crates/ironclaw_webui_v2/frontend/src/lib/toast.test.ts b/crates/ironclaw_webui_v2/frontend/src/lib/toast.test.ts
index 1a91d25b49c..c357830fcaf 100644
--- a/crates/ironclaw_webui_v2/frontend/src/lib/toast.test.ts
+++ b/crates/ironclaw_webui_v2/frontend/src/lib/toast.test.ts
@@ -3,12 +3,14 @@ import { beforeEach, test, vi } from "vitest";
const hotToast = vi.hoisted(() => ({
blank: vi.fn((..._args: unknown[]) => "blank-id"),
+ dismiss: vi.fn((..._args: unknown[]) => {}),
error: vi.fn((..._args: unknown[]) => "error-id"),
success: vi.fn((..._args: unknown[]) => "success-id"),
}));
vi.mock("react-hot-toast", () => ({
default: Object.assign(hotToast.blank, {
+ dismiss: hotToast.dismiss,
error: hotToast.error,
success: hotToast.success,
}),
@@ -17,6 +19,7 @@ vi.mock("react-hot-toast", () => ({
import {
DEFAULT_ERROR_TOAST_DURATION,
DEFAULT_TOAST_DURATION,
+ dismissToast,
toast,
} from "./toast";
@@ -62,3 +65,10 @@ test("toast preserves an explicit duration", () => {
},
]);
});
+
+test("dismissToast removes the matching active toast", () => {
+ dismissToast("error-id");
+ dismissToast(null);
+
+ assert.deepEqual(hotToast.dismiss.mock.calls, [["error-id"]]);
+});
diff --git a/crates/ironclaw_webui_v2/frontend/src/lib/toast.ts b/crates/ironclaw_webui_v2/frontend/src/lib/toast.ts
index 2913b3b5701..5e8af0aa11d 100644
--- a/crates/ironclaw_webui_v2/frontend/src/lib/toast.ts
+++ b/crates/ironclaw_webui_v2/frontend/src/lib/toast.ts
@@ -28,3 +28,8 @@ export function toast(message: string, opts: ToastOptions = {}) {
if (tone === "success") return hotToast.success(message, options);
return hotToast(message, options);
}
+
+export function dismissToast(id: string | null | undefined) {
+ if (id == null) return;
+ hotToast.dismiss(id);
+}
diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/automations/automations-page.tsx b/crates/ironclaw_webui_v2/frontend/src/pages/automations/automations-page.tsx
index 0c2e75dc359..28ca6aa8ec3 100644
--- a/crates/ironclaw_webui_v2/frontend/src/pages/automations/automations-page.tsx
+++ b/crates/ironclaw_webui_v2/frontend/src/pages/automations/automations-page.tsx
@@ -1,5 +1,4 @@
import React from "react";
-import { Icon } from "../../design-system/icons";
import { useT } from "../../lib/i18n";
import { AutomationDeliveryDefaultsPanel } from "./components/automation-delivery-defaults-panel";
import { AutomationsList } from "./components/automations-list";
@@ -58,27 +57,6 @@ export function AutomationsPage() {
{t("automations.error.loadFailed")}
)}
- {automationsState.actionError &&
- (
-
-
- {t("automations.error.actionFailed")}
-
-
-
- )}
-
{showErrorOnly
? null
: (
diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts
index cc195de4ccc..a7575e981c5 100644
--- a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts
+++ b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts
@@ -16,9 +16,10 @@ function loadUseAutomations() {
},
}).outputText;
const mutationConfigs = [];
- const stateUpdates = [];
const invalidations = [];
const consoleErrors = [];
+ const dismissedToastIds = [];
+ const toastCalls = [];
const context = {
AUTOMATIONS_BASE_REFETCH_MS: 30000,
React: {
@@ -26,13 +27,15 @@ function loadUseAutomations() {
useEffect: () => {},
useMemo: (factory) => factory(),
useRef: (initial) => ({ current: initial }),
- useState: (initial) => [initial, (value) => stateUpdates.push(value)],
},
automationSummary: () => ({}),
console: {
error: (...args) => consoleErrors.push(args),
},
deleteAutomation: () => {},
+ dismissToast: (id) => {
+ if (id != null) dismissedToastIds.push(id);
+ },
globalThis: {},
listAutomations: () => {},
nextAutomationsRefetchDelay: () => null,
@@ -42,7 +45,11 @@ function loadUseAutomations() {
resumeAutomation: () => {},
setTimeout,
clearTimeout,
- useI18n: () => ({ lang: "en", t: (key) => key }),
+ toast: (...args) => {
+ toastCalls.push(args);
+ return "toast-41";
+ },
+ useI18n: () => ({ lang: "en", t: (key) => `translated:${key}` }),
useMutation: (config) => {
mutationConfigs.push(config);
return { isPending: false, mutate: () => {} };
@@ -66,19 +73,21 @@ function loadUseAutomations() {
return {
consoleErrors,
+ dismissedToastIds,
invalidations,
mutationConfigs,
- stateUpdates,
+ toastCalls,
useAutomations: context.globalThis.__testExports.useAutomations,
};
}
-test("automation action results only update the latest action state", () => {
+test("only the latest automation action controls the error toast", () => {
const {
consoleErrors,
+ dismissedToastIds,
invalidations,
mutationConfigs,
- stateUpdates,
+ toastCalls,
useAutomations,
} = loadUseAutomations();
useAutomations();
@@ -103,23 +112,30 @@ test("automation action results only update the latest action state", () => {
"automation-1",
firstAction
);
- assert.deepEqual(stateUpdates, [false, false]);
+ assert.deepEqual(toastCalls, []);
mutationConfigs[2].onError(
new Error("raw backend detail"),
{ automationId: "automation-2", name: "New name" },
secondAction
);
- assert.deepEqual(stateUpdates, [false, false, true]);
+ assert.deepEqual(
+ JSON.parse(JSON.stringify(toastCalls)),
+ [[
+ "translated:automations.error.actionFailed",
+ { tone: "error" },
+ ]]
+ );
assert.deepEqual(consoleErrors, []);
const thirdAction = mutationConfigs[1].onMutate("automation-3");
+ assert.deepEqual(dismissedToastIds, ["toast-41"]);
mutationConfigs[2].onSuccess(
{ updated: true },
{ automationId: "automation-2", name: "New name" },
secondAction
);
- assert.deepEqual(stateUpdates, [false, false, true, false]);
+ assert.deepEqual(dismissedToastIds, ["toast-41"]);
assert.equal(invalidations.length, 1);
mutationConfigs[1].onSuccess(
@@ -127,6 +143,6 @@ test("automation action results only update the latest action state", () => {
"automation-3",
thirdAction
);
- assert.deepEqual(stateUpdates, [false, false, true, false, false]);
+ assert.deepEqual(dismissedToastIds, ["toast-41"]);
assert.equal(invalidations.length, 2);
});
diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts
index fc6bd2daf07..b8ba8e2d2b1 100644
--- a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts
+++ b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts
@@ -8,6 +8,7 @@ import {
resumeAutomation,
} from "../../../lib/api";
import { useI18n } from "../../../lib/i18n";
+import { dismissToast, toast } from "../../../lib/toast";
import {
automationSummary,
@@ -40,8 +41,8 @@ function isLatestAutomationAction(
export function useAutomations(includeCompleted = false) {
const { t, lang } = useI18n();
const queryClient = useQueryClient();
- const [hasActionError, setHasActionError] = React.useState(false);
const latestActionSequence = React.useRef(0);
+ const actionErrorToastId = React.useRef(null);
const query = useQuery({
queryKey: ["automations", { includeCompleted }],
queryFn: () =>
@@ -87,7 +88,8 @@ export function useAutomations(includeCompleted = false) {
queryClient.invalidateQueries({ queryKey: ["automations"] });
}, [queryClient]);
const clearActionError = React.useCallback(() => {
- setHasActionError(false);
+ dismissToast(actionErrorToastId.current);
+ actionErrorToastId.current = null;
}, []);
const beginAction = React.useCallback((): ActionMutationContext => {
const sequence = latestActionSequence.current + 1;
@@ -101,11 +103,14 @@ export function useAutomations(includeCompleted = false) {
context: ActionMutationContext | undefined
) => {
// A newer action deliberately supersedes older results: starting any
- // action clears the banner, and a late completion must not resurrect it.
+ // action clears the toast, and a late completion must not resurrect it.
if (isLatestAutomationAction(context, latestActionSequence.current)) {
- setHasActionError(true);
+ clearActionError();
+ actionErrorToastId.current = toast(t("automations.error.actionFailed"), {
+ tone: "error",
+ });
}
- }, []);
+ }, [clearActionError, t]);
const handleActionSuccess = React.useCallback((
_data: unknown,
_variables: unknown,
@@ -154,8 +159,6 @@ export function useAutomations(includeCompleted = false) {
renameMutation.isPending ||
deleteMutation.isPending,
error: query.error || null,
- actionError: hasActionError,
- dismissActionError: clearActionError,
pauseAutomation: pauseMutation.mutate,
resumeAutomation: resumeMutation.mutate,
renameAutomation: renameMutation.mutate,
From a9f94f778d46e15084ccc7ed1263ae57ba96e36b Mon Sep 17 00:00:00 2001
From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com>
Date: Fri, 17 Jul 2026 18:33:03 +0800
Subject: [PATCH 7/9] test(e2e): cover automation action error toast
---
tests/e2e/helpers.py | 2 +-
.../scenarios/test_reborn_webui_v2_smoke.py | 27 ++++++++++---------
2 files changed, 15 insertions(+), 14 deletions(-)
diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py
index 5be45685fee..ec8ba8b1491 100644
--- a/tests/e2e/helpers.py
+++ b/tests/e2e/helpers.py
@@ -375,7 +375,7 @@
"automation_rename_button": "[data-testid='automation-rename-button']",
"automation_rename_input": "[data-testid='automation-rename-input']",
"automation_rename_save": "[data-testid='automation-rename-save']",
- "automation_action_error": "[data-testid='automation-action-error']",
+ "toast": "[data-testid='toast']",
"automation_run_open": "[data-testid='automation-run-open']",
"automation_run_logs": "[data-testid='automation-run-logs']",
"skills_card": "#skills-list .ext-card",
diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
index 34502417eac..a0d6040a21c 100644
--- a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
+++ b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
@@ -516,10 +516,10 @@ async def test_reborn_v2_automation_rename_persists_from_ui(
assert renamed["automation_id"] == automation_id
-async def test_reborn_v2_automation_action_error_is_safe_dismissible_and_cleared_on_retry(
+async def test_reborn_v2_automation_action_error_toast_is_safe_dismissible_and_cleared_on_retry(
reborn_v2_server, reborn_v2_browser
):
- """Automation mutations keep raw errors private and clear stale failures."""
+ """Automation mutation toasts stay visible, private, and clear on retry."""
automation_id = "11111111-2222-3333-4444-555555555555"
automation_name = "Safe action error regression"
raw_error = "postgres failed: secret_internal_automation_table"
@@ -585,7 +585,9 @@ async def handle_automations(route) -> None:
await page.route("**/api/webchat/v2/automations**", handle_automations)
row_selector = SEL_V2["automation_row_for"].format(id=automation_id)
- error_banner = page.locator(SEL_V2["automation_action_error"])
+ error_toast = page.locator(SEL_V2["toast"]).filter(
+ has_text="Unable to update the automation. Please try again."
+ )
async def submit_rename(name: str) -> None:
row = page.locator(row_selector)
@@ -599,33 +601,32 @@ async def submit_rename(name: str) -> None:
await page.locator(SEL_V2["automation_rename_save"]).click()
try:
- await page.goto(f"{reborn_v2_server}/v2/automations?token={REBORN_V2_AUTH_TOKEN}")
+ await page.goto(f"{reborn_v2_server}/automations?token={REBORN_V2_AUTH_TOKEN}")
await submit_rename("First failed rename")
- await expect(error_banner).to_be_visible(timeout=10000)
- await expect(error_banner).to_have_text(
+ await expect(error_toast).to_be_visible(timeout=10000)
+ await expect(error_toast).to_have_text(
"Unable to update the automation. Please try again."
)
- await expect(error_banner).not_to_contain_text(raw_error)
+ await expect(error_toast).not_to_contain_text(raw_error)
assert not any(raw_error in message for message in console_messages)
-
- await error_banner.get_by_role("button", name="Dismiss").click()
- await expect(error_banner).to_have_count(0)
+ await error_toast.get_by_role("button", name="Dismiss").click()
+ await expect(error_toast).to_have_count(0, timeout=3000)
pause_button = page.get_by_role(
"button", name=f"Pause: {automation_name}", exact=True
)
await pause_button.click()
- await expect(error_banner).to_be_visible(timeout=10000)
+ await expect(error_toast).to_be_visible(timeout=10000)
assert not any(raw_error in message for message in console_messages)
await pause_button.click()
await asyncio.wait_for(retry_started.wait(), timeout=10)
- await expect(error_banner).to_have_count(0)
+ await expect(error_toast).to_have_count(0)
release_retry.set()
await asyncio.wait_for(retry_completed.wait(), timeout=10)
- await expect(error_banner).to_have_count(0)
+ await expect(error_toast).to_have_count(0)
assert not any(raw_error in message for message in console_messages)
assert mutation_requests == [
(
From c00fce16e86c61a24f08e2a8e3ffa25a88858ccc Mon Sep 17 00:00:00 2001
From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com>
Date: Fri, 17 Jul 2026 20:00:24 +0800
Subject: [PATCH 8/9] refactor(webui): centralize automation mutation lifecycle
---
.../frontend/src/lib/i18n.test.ts | 10 +
.../automations/hooks/useAutomations.test.ts | 178 +++++------------
.../pages/automations/hooks/useAutomations.ts | 188 ++++++++++++------
3 files changed, 188 insertions(+), 188 deletions(-)
diff --git a/crates/ironclaw_webui_v2/frontend/src/lib/i18n.test.ts b/crates/ironclaw_webui_v2/frontend/src/lib/i18n.test.ts
index 81d6fe61728..5f4bd91aceb 100644
--- a/crates/ironclaw_webui_v2/frontend/src/lib/i18n.test.ts
+++ b/crates/ironclaw_webui_v2/frontend/src/lib/i18n.test.ts
@@ -230,6 +230,16 @@ test("locale packs include skill auto-activation controls", () => {
}
});
+test("locale packs include automation action failure copy", () => {
+ const key = "automations.error.actionFailed";
+
+ for (const locale of LOCALES) {
+ const pack = loadLocalePack(locale);
+ assert.equal(typeof pack[key], "string", `${locale} missing ${key}`);
+ assert.notEqual(pack[key].trim(), "", `${locale} has empty ${key}`);
+ }
+});
+
test("locale packs include admin write-only secret management copy", () => {
const requiredKeys = [
"admin.user.secrets.title",
diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts
index a7575e981c5..90e27c1db9e 100644
--- a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts
+++ b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts
@@ -1,148 +1,80 @@
-// @ts-nocheck
import assert from "node:assert/strict";
-import { readFileSync } from "node:fs";
-import vm from "node:vm";
-import ts from "typescript";
import { test } from "vitest";
-import { sourceTextForVmTest } from "../../../test-support/vm-module-harness";
+import {
+ createAutomationMutationConfig,
+ createAutomationMutationLifecycle,
+} from "./useAutomations";
-function loadUseAutomations() {
- const source = readFileSync(new URL("./useAutomations.ts", import.meta.url), "utf8");
- const transpiled = ts.transpileModule(source, {
- compilerOptions: {
- module: ts.ModuleKind.ESNext,
- target: ts.ScriptTarget.ES2022,
- },
- }).outputText;
- const mutationConfigs = [];
- const invalidations = [];
- const consoleErrors = [];
- const dismissedToastIds = [];
- const toastCalls = [];
- const context = {
- AUTOMATIONS_BASE_REFETCH_MS: 30000,
- React: {
- useCallback: (callback) => callback,
- useEffect: () => {},
- useMemo: (factory) => factory(),
- useRef: (initial) => ({ current: initial }),
- },
- automationSummary: () => ({}),
- console: {
- error: (...args) => consoleErrors.push(args),
- },
- deleteAutomation: () => {},
- dismissToast: (id) => {
- if (id != null) dismissedToastIds.push(id);
- },
- globalThis: {},
- listAutomations: () => {},
- nextAutomationsRefetchDelay: () => null,
- normalizeAutomations: () => [],
- pauseAutomation: () => {},
- renameAutomation: () => {},
- resumeAutomation: () => {},
- setTimeout,
- clearTimeout,
- toast: (...args) => {
- toastCalls.push(args);
- return "toast-41";
+test("automation mutation configs share an explicit latest-action lifecycle", async () => {
+ const latestActionSequence = { current: 0 };
+ const actionErrorToastId = { current: null as string | null };
+ const dismissedToastIds: Array = [];
+ const shownToastIds: string[] = [];
+ let invalidationCount = 0;
+
+ const lifecycle = createAutomationMutationLifecycle({
+ latestActionSequence,
+ actionErrorToastId,
+ dismissErrorToast: (id) => dismissedToastIds.push(id),
+ showErrorToast: () => {
+ const id = `toast-${shownToastIds.length + 1}`;
+ shownToastIds.push(id);
+ return id;
},
- useI18n: () => ({ lang: "en", t: (key) => `translated:${key}` }),
- useMutation: (config) => {
- mutationConfigs.push(config);
- return { isPending: false, mutate: () => {} };
+ invalidateAutomations: () => {
+ invalidationCount += 1;
},
- useQuery: () => ({
- data: { automations: [], scheduler_enabled: true },
- error: null,
- isFetching: false,
- isLoading: false,
- refetch: () => {},
- }),
- useQueryClient: () => ({
- invalidateQueries: (query) => invalidations.push(query),
- }),
- };
-
- vm.runInNewContext(
- sourceTextForVmTest(transpiled, ["useAutomations"]),
- context
- );
-
- return {
- consoleErrors,
- dismissedToastIds,
- invalidations,
- mutationConfigs,
- toastCalls,
- useAutomations: context.globalThis.__testExports.useAutomations,
- };
-}
-
-test("only the latest automation action controls the error toast", () => {
- const {
- consoleErrors,
- dismissedToastIds,
- invalidations,
- mutationConfigs,
- toastCalls,
- useAutomations,
- } = loadUseAutomations();
- useAutomations();
+ });
+ const pause = async (automationId: string) => ({ automationId });
+ const rename = async (variables: { automationId: string; name: string }) =>
+ variables;
+ const pauseConfig = createAutomationMutationConfig(pause, lifecycle);
+ const renameConfig = createAutomationMutationConfig(rename, lifecycle);
- assert.equal(mutationConfigs.length, 4);
- for (const callbackName of ["onMutate", "onError", "onSuccess"]) {
- assert.equal(
- new Set(mutationConfigs.map((config) => config[callbackName])).size,
- 1,
- `all automation mutations must share ${callbackName}`
- );
+ assert.equal(pauseConfig.mutationFn, pause);
+ assert.equal(renameConfig.mutationFn, rename);
+ for (const callbackName of ["onMutate", "onError", "onSuccess"] as const) {
+ assert.equal(pauseConfig[callbackName], lifecycle[callbackName]);
+ assert.equal(renameConfig[callbackName], lifecycle[callbackName]);
}
- const firstAction = mutationConfigs[0].onMutate("automation-1");
- const secondAction = mutationConfigs[2].onMutate({
+ const firstAction = await lifecycle.onMutate("automation-1");
+ const secondAction = await lifecycle.onMutate({
automationId: "automation-2",
name: "New name",
});
+ assert.deepEqual(dismissedToastIds, []);
- mutationConfigs[0].onError(
+ lifecycle.onError(
new Error("raw backend detail"),
"automation-1",
firstAction
);
- assert.deepEqual(toastCalls, []);
+ assert.deepEqual(shownToastIds, []);
- mutationConfigs[2].onError(
+ lifecycle.onError(
new Error("raw backend detail"),
{ automationId: "automation-2", name: "New name" },
secondAction
);
- assert.deepEqual(
- JSON.parse(JSON.stringify(toastCalls)),
- [[
- "translated:automations.error.actionFailed",
- { tone: "error" },
- ]]
- );
- assert.deepEqual(consoleErrors, []);
+ assert.deepEqual(shownToastIds, ["toast-1"]);
+ assert.equal(actionErrorToastId.current, "toast-1");
- const thirdAction = mutationConfigs[1].onMutate("automation-3");
- assert.deepEqual(dismissedToastIds, ["toast-41"]);
- mutationConfigs[2].onSuccess(
- { updated: true },
- { automationId: "automation-2", name: "New name" },
- secondAction
- );
- assert.deepEqual(dismissedToastIds, ["toast-41"]);
- assert.equal(invalidations.length, 1);
+ // A late callback from the older action must not dismiss or overwrite the
+ // toast now owned by the latest action.
+ lifecycle.onError(new Error("late failure"), "automation-1", firstAction);
+ lifecycle.onSuccess({ updated: true }, "automation-1", firstAction);
+ assert.deepEqual(shownToastIds, ["toast-1"]);
+ assert.deepEqual(dismissedToastIds, []);
+ assert.equal(actionErrorToastId.current, "toast-1");
+ assert.equal(invalidationCount, 1);
- mutationConfigs[1].onSuccess(
- { updated: true },
- "automation-3",
- thirdAction
- );
- assert.deepEqual(dismissedToastIds, ["toast-41"]);
- assert.equal(invalidations.length, 2);
+ const thirdAction = await lifecycle.onMutate("automation-3");
+ assert.deepEqual(dismissedToastIds, ["toast-1"]);
+ assert.equal(actionErrorToastId.current, null);
+
+ lifecycle.onSuccess({ updated: true }, "automation-3", thirdAction);
+ assert.deepEqual(dismissedToastIds, ["toast-1"]);
+ assert.equal(invalidationCount, 2);
});
diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts
index b8ba8e2d2b1..ed127ad4249 100644
--- a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts
+++ b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts
@@ -1,4 +1,10 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ type MutationFunction,
+ type UseMutationOptions,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
import React from "react";
import {
deleteAutomation,
@@ -27,15 +33,83 @@ type RenameAutomationVariables = {
name: string;
};
-type ActionMutationContext = {
+export type ActionMutationContext = {
sequence: number;
};
-function isLatestAutomationAction(
- context: ActionMutationContext | undefined,
- latestSequence: number
-) {
- return context?.sequence === latestSequence;
+type MutableRef = {
+ current: T;
+};
+
+export type AutomationMutationLifecycle = {
+ onMutate: (_variables: unknown) => ActionMutationContext;
+ onError: (
+ _error: unknown,
+ _variables: unknown,
+ context: ActionMutationContext | undefined
+ ) => void;
+ onSuccess: (
+ _data: unknown,
+ _variables: unknown,
+ context: ActionMutationContext | undefined
+ ) => void;
+};
+
+type AutomationMutationLifecycleOptions = {
+ latestActionSequence: MutableRef;
+ actionErrorToastId: MutableRef;
+ dismissErrorToast: (id: string | null | undefined) => void;
+ showErrorToast: () => string;
+ invalidateAutomations: () => void;
+};
+
+export function createAutomationMutationLifecycle({
+ latestActionSequence,
+ actionErrorToastId,
+ dismissErrorToast,
+ showErrorToast,
+ invalidateAutomations,
+}: AutomationMutationLifecycleOptions): AutomationMutationLifecycle {
+ const clearActionError = () => {
+ if (actionErrorToastId.current !== null) {
+ dismissErrorToast(actionErrorToastId.current);
+ }
+ actionErrorToastId.current = null;
+ };
+
+ return {
+ onMutate: () => {
+ const sequence = latestActionSequence.current + 1;
+ latestActionSequence.current = sequence;
+ clearActionError();
+ return { sequence };
+ },
+ onError: (_error, _variables, context) => {
+ // A newer action deliberately supersedes older results: a late failure
+ // must neither dismiss nor replace the latest action's toast.
+ if (context?.sequence !== latestActionSequence.current) return;
+ clearActionError();
+ actionErrorToastId.current = showErrorToast();
+ },
+ onSuccess: (_data, _variables, context) => {
+ if (context?.sequence === latestActionSequence.current) {
+ clearActionError();
+ }
+ invalidateAutomations();
+ },
+ };
+}
+
+export function createAutomationMutationConfig(
+ mutationFn: MutationFunction,
+ lifecycle: AutomationMutationLifecycle
+): UseMutationOptions {
+ return {
+ mutationFn,
+ onMutate: lifecycle.onMutate,
+ onError: lifecycle.onError,
+ onSuccess: lifecycle.onSuccess,
+ };
}
export function useAutomations(includeCompleted = false) {
@@ -87,65 +161,49 @@ export function useAutomations(includeCompleted = false) {
const invalidateAutomations = React.useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["automations"] });
}, [queryClient]);
- const clearActionError = React.useCallback(() => {
- dismissToast(actionErrorToastId.current);
- actionErrorToastId.current = null;
- }, []);
- const beginAction = React.useCallback((): ActionMutationContext => {
- const sequence = latestActionSequence.current + 1;
- latestActionSequence.current = sequence;
- clearActionError();
- return { sequence };
- }, [clearActionError]);
- const showActionError = React.useCallback((
- _error: unknown,
- _variables: unknown,
- context: ActionMutationContext | undefined
- ) => {
- // A newer action deliberately supersedes older results: starting any
- // action clears the toast, and a late completion must not resurrect it.
- if (isLatestAutomationAction(context, latestActionSequence.current)) {
- clearActionError();
- actionErrorToastId.current = toast(t("automations.error.actionFailed"), {
+ const showActionErrorToast = React.useCallback(
+ () =>
+ toast(t("automations.error.actionFailed"), {
tone: "error",
- });
- }
- }, [clearActionError, t]);
- const handleActionSuccess = React.useCallback((
- _data: unknown,
- _variables: unknown,
- context: ActionMutationContext | undefined
- ) => {
- if (isLatestAutomationAction(context, latestActionSequence.current)) {
- clearActionError();
- }
- invalidateAutomations();
- }, [clearActionError, invalidateAutomations]);
- const pauseMutation = useMutation({
- mutationFn: (automationId: string) => pauseAutomation({ automationId }),
- onMutate: beginAction,
- onError: showActionError,
- onSuccess: handleActionSuccess,
- });
- const resumeMutation = useMutation({
- mutationFn: (automationId: string) => resumeAutomation({ automationId }),
- onMutate: beginAction,
- onError: showActionError,
- onSuccess: handleActionSuccess,
- });
- const renameMutation = useMutation({
- mutationFn: ({ automationId, name }: RenameAutomationVariables) =>
- renameAutomation({ automationId, name }),
- onMutate: beginAction,
- onError: showActionError,
- onSuccess: handleActionSuccess,
- });
- const deleteMutation = useMutation({
- mutationFn: (automationId: string) => deleteAutomation({ automationId }),
- onMutate: beginAction,
- onError: showActionError,
- onSuccess: handleActionSuccess,
- });
+ }),
+ [t]
+ );
+ const mutationLifecycle = React.useMemo(
+ () =>
+ createAutomationMutationLifecycle({
+ latestActionSequence,
+ actionErrorToastId,
+ dismissErrorToast: dismissToast,
+ showErrorToast: showActionErrorToast,
+ invalidateAutomations,
+ }),
+ [invalidateAutomations, showActionErrorToast]
+ );
+ const pauseMutation = useMutation(
+ createAutomationMutationConfig(
+ (automationId: string) => pauseAutomation({ automationId }),
+ mutationLifecycle
+ )
+ );
+ const resumeMutation = useMutation(
+ createAutomationMutationConfig(
+ (automationId: string) => resumeAutomation({ automationId }),
+ mutationLifecycle
+ )
+ );
+ const renameMutation = useMutation(
+ createAutomationMutationConfig(
+ ({ automationId, name }: RenameAutomationVariables) =>
+ renameAutomation({ automationId, name }),
+ mutationLifecycle
+ )
+ );
+ const deleteMutation = useMutation(
+ createAutomationMutationConfig(
+ (automationId: string) => deleteAutomation({ automationId }),
+ mutationLifecycle
+ )
+ );
return {
automations,
From 5edd838766b4032065b97319044e0c0b2878bdb6 Mon Sep 17 00:00:00 2001
From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com>
Date: Fri, 17 Jul 2026 20:17:38 +0800
Subject: [PATCH 9/9] test(e2e): use canonical automation page fixture
---
tests/e2e/helpers.py | 2 --
tests/e2e/scenarios/test_reborn_webui_v2_smoke.py | 6 ++----
2 files changed, 2 insertions(+), 6 deletions(-)
diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py
index 22f2dbb01f4..489a325a2a3 100644
--- a/tests/e2e/helpers.py
+++ b/tests/e2e/helpers.py
@@ -265,7 +265,6 @@
"workspace_directory_entry_for": (
"[data-testid='workspace-directory-entry'][data-entry-path='{path}']"
),
- "toast": "[data-testid='toast']",
"thread_delete_for": (
'[data-testid="thread-delete"][data-thread-id="{id}"]'
),
@@ -382,7 +381,6 @@
"automation_rename_button": "[data-testid='automation-rename-button']",
"automation_rename_input": "[data-testid='automation-rename-input']",
"automation_rename_save": "[data-testid='automation-rename-save']",
- "toast": "[data-testid='toast']",
"automation_run_open": "[data-testid='automation-run-open']",
"automation_run_logs": "[data-testid='automation-run-logs']",
"skills_card": "#skills-list .ext-card",
diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
index a0d6040a21c..27cf9d10939 100644
--- a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
+++ b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
@@ -517,7 +517,7 @@ async def test_reborn_v2_automation_rename_persists_from_ui(
async def test_reborn_v2_automation_action_error_toast_is_safe_dismissible_and_cleared_on_retry(
- reborn_v2_server, reborn_v2_browser
+ reborn_v2_server, reborn_v2_page
):
"""Automation mutation toasts stay visible, private, and clear on retry."""
automation_id = "11111111-2222-3333-4444-555555555555"
@@ -530,8 +530,7 @@ async def test_reborn_v2_automation_action_error_toast_is_safe_dismissible_and_c
release_retry = asyncio.Event()
retry_completed = asyncio.Event()
- context = await reborn_v2_browser.new_context(viewport={"width": 1280, "height": 720})
- page = await context.new_page()
+ page = reborn_v2_page
page.on("console", lambda message: console_messages.append(message.text))
async def handle_automations(route) -> None:
@@ -644,7 +643,6 @@ async def submit_rename(name: str) -> None:
]
finally:
release_retry.set()
- await context.close()
async def test_reborn_v2_automation_failed_run_actions_are_clickable(