fix(web): catch unhandled promise rejections on config save buttons (#1346)

fix(web): catch unhandled promise rejections on config save buttons (#1346)
This commit is contained in:
Bruno Clermont authored and GitHub committed 2026-07-05 02:27:14 +07:00
1 parent 150c2b3c01
commit 6d24147a38
17 files changed
+87 -35

No files matched your search

+17 -10
View File
@@ -106,6 +106,20 @@ func seedTenantAgent(t *testing.T, db *sql.DB) (tenantID, agentID uuid.UUID) {
// Cleanup after test — delete in FK order (children first, parents last).
t.Cleanup(func() {
// Knowledge stores — must run BEFORE agent_teams is deleted. vault_documents.team_id
// is ON DELETE SET NULL with a BEFORE UPDATE trigger that force-sets scope='personal',
// which violates vault_documents_scope_consistency for team-scoped docs whose agent_id
// is NULL. Deleting the rows outright avoids the trigger entirely.
// vault_links has no tenant_id column (it links vault_documents by from/to_doc_id) and
// cascades automatically via vault_links_{from,to}_doc_id_fkey ON DELETE CASCADE, so no
// explicit delete is needed here.
db.Exec("DELETE FROM vault_documents WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM kg_dedup_candidates WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM kg_relations WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM kg_entities WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM memory_chunks WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM memory_documents WHERE tenant_id = $1", tenantID)
// Team-related (deepest children first)
db.Exec("DELETE FROM team_task_comments WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM team_task_events WHERE tenant_id = $1", tenantID)
@@ -121,15 +135,6 @@ func seedTenantAgent(t *testing.T, db *sql.DB) (tenantID, agentID uuid.UUID) {
db.Exec("DELETE FROM cron_run_logs WHERE job_id IN (SELECT id FROM cron_jobs WHERE tenant_id = $1)", tenantID)
db.Exec("DELETE FROM cron_jobs WHERE tenant_id = $1", tenantID)
// Knowledge stores
db.Exec("DELETE FROM vault_links WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM vault_documents WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM kg_dedup_candidates WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM kg_relations WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM kg_entities WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM memory_chunks WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM memory_documents WHERE tenant_id = $1", tenantID)
// Sessions
db.Exec("DELETE FROM sessions WHERE tenant_id = $1", tenantID)
@@ -156,7 +161,9 @@ func seedTenantAgent(t *testing.T, db *sql.DB) (tenantID, agentID uuid.UUID) {
db.Exec("DELETE FROM agent_context_files WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM user_context_files WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM user_agent_overrides WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM agent_user_profiles WHERE agent_id = $1", agentID)
// Note: agent_user_profiles does not exist in the current schema (the table is
// user_agent_profiles, scoped by tenant_id + agent_id; there is nothing per-agent
// to clean here beyond what tenant-scoped deletes below already cover).
db.Exec("DELETE FROM agent_evolution_suggestions WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM agent_evolution_metrics WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM agents WHERE id = $1", agentID)
+18 -11
View File
@@ -173,6 +173,21 @@ func assertNotEmpty(t *testing.T, result any, msg string) {
// cleanupTenant removes all tenant data in FK order.
func cleanupTenant(db *sql.DB, tenantID, agentID uuid.UUID) {
// Knowledge stores — must run BEFORE agent_teams is deleted. vault_documents.team_id
// is ON DELETE SET NULL with a BEFORE UPDATE trigger that force-sets scope='personal',
// which violates vault_documents_scope_consistency for team-scoped docs whose agent_id
// is NULL. Deleting the rows outright avoids the trigger entirely.
// vault_links has no tenant_id column (it links vault_documents by from/to_doc_id) and
// cascades automatically via vault_links_{from,to}_doc_id_fkey ON DELETE CASCADE, so no
// explicit delete is needed here.
db.Exec("DELETE FROM episodic_summaries WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM vault_documents WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM kg_dedup_candidates WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM kg_relations WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM kg_entities WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM memory_chunks WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM memory_documents WHERE tenant_id = $1", tenantID)
// Team-related
db.Exec("DELETE FROM team_task_comments WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM team_task_events WHERE tenant_id = $1", tenantID)
@@ -181,16 +196,6 @@ func cleanupTenant(db *sql.DB, tenantID, agentID uuid.UUID) {
db.Exec("DELETE FROM agent_team_members WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM agent_teams WHERE tenant_id = $1", tenantID)
// Knowledge stores
db.Exec("DELETE FROM episodic_summaries WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM vault_links WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM vault_documents WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM kg_dedup_candidates WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM kg_relations WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM kg_entities WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM memory_chunks WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM memory_documents WHERE tenant_id = $1", tenantID)
// Sessions
db.Exec("DELETE FROM sessions WHERE tenant_id = $1", tenantID)
@@ -218,7 +223,9 @@ func cleanupTenant(db *sql.DB, tenantID, agentID uuid.UUID) {
db.Exec("DELETE FROM agent_context_files WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM user_context_files WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM user_agent_overrides WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM agent_user_profiles WHERE agent_id = $1", agentID)
// Note: agent_user_profiles does not exist in the current schema (the table is
// user_agent_profiles, agent_id REFERENCES agents(id) ON DELETE CASCADE — cleaned up
// automatically when the agents row below is deleted).
db.Exec("DELETE FROM agent_evolution_suggestions WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM agent_evolution_metrics WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM agents WHERE id = $1", agentID)
+2 -1
View File
@@ -347,7 +347,8 @@ func TestTenantIsolation_VaultStore(t *testing.T) {
t.Fatalf("create vault doc: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM vault_links WHERE tenant_id = $1", tenantA)
// vault_links has no tenant_id column; it cascades via
// vault_links_{from,to}_doc_id_fkey ON DELETE CASCADE when vault_documents is deleted.
db.Exec("DELETE FROM vault_documents WHERE id = $1", docID)
})
+25
View File
@@ -3,6 +3,31 @@ import { createRoot } from "react-dom/client";
import "./i18n";
import App from "./App";
import "./index.css";
import { ApiError } from "@/api/errors";
/**
* Global safety net for unhandled promise rejections from API calls.
*
* Every data-fetching hook in this app (see src/pages/*\/hooks/*.ts) already
* catches `ApiError`s, shows a `toast.error(...)` with a user-friendly message,
* and re-throws so the calling component can react locally (e.g. keep a form
* open, avoid resetting "dirty" state on failure). Some call sites forget to
* `catch` that re-thrown promise, which produces a noisy, user-invisible
* "Uncaught (in promise) ApiError" in the console even though the user already
* saw a toast for the underlying failure.
*
* This handler does NOT show its own toast (that would double up with the one
* already shown by the originating hook) — it only suppresses the redundant
* browser-level "unhandled rejection" noise for errors known to originate from
* our API layer, which have already been surfaced to the user via toast. It
* never calls `preventDefault()` for non-`ApiError` rejections, so genuine
* unexpected bugs still surface normally (console + any error-reporting tooling).
*/
window.addEventListener("unhandledrejection", (event) => {
if (event.reason instanceof ApiError) {
event.preventDefault();
}
});
const LOADER_MIN_MS = 800;
const loaderStart = performance.now();
@@ -96,7 +96,7 @@ export function BehaviorSection({ config, onPatch, saving }: Props) {
tools: { scrub_credentials: security.scrub_credentials },
sessions,
channels: { pending_compaction: pendingCompaction },
});
}).catch(() => {});
};
return (
@@ -147,7 +147,7 @@ export function BindingsSection({ data, onSave, saving }: Props) {
{dirty && (
<div className="flex justify-end pt-2">
<Button size="sm" onClick={() => onSave(draft)} disabled={saving} className="gap-1.5">
<Button size="sm" onClick={() => { onSave(draft).catch(() => {}); }} disabled={saving} className="gap-1.5">
<Save className="h-3.5 w-3.5" /> {saving ? t("saving") : t("save")}
</Button>
</div>
@@ -80,7 +80,7 @@ export function ChannelsSection({ data, onSave, saving }: Props) {
}
toSave[ch] = copy;
}
onSave(toSave);
onSave(toSave).catch(() => {});
};
if (!data) return null;
@@ -97,7 +97,7 @@ export function CronSection({ data, onSave, saving }: Props) {
toast.error(t("cron.invalidTimezone", "Invalid timezone"));
return;
}
onSave(draft);
onSave(draft).catch(() => {});
}} disabled={saving} className="gap-1.5">
<Save className="h-3.5 w-3.5" /> {saving ? t("saving") : t("save")}
</Button>
@@ -74,7 +74,7 @@ export function ProvidersSection({ data, onSave, saving }: Props) {
}
toSave[key] = clean;
}
onSave(toSave);
onSave(toSave).catch(() => {});
};
if (!data) return null;
@@ -139,7 +139,7 @@ export function QuotaSection({ data, onSave, saving }: Props) {
<div className="flex justify-end pt-2">
<Button
size="sm"
onClick={() => onSave({ quota: draft })}
onClick={() => { onSave({ quota: draft }).catch(() => {}); }}
disabled={saving}
className="gap-1.5"
>
@@ -51,7 +51,7 @@ export function ServerSection({ data, onSave, saving }: Props) {
const handleSave = () => {
const toSave = { ...draft };
if (isSecret(toSave.token)) delete toSave.token;
onSave(toSave);
onSave(toSave).catch(() => {});
};
if (!data) return null;
@@ -0,0 +1,8 @@
import { expect, describe, it } from "vitest";
import { ShellSecuritySection } from "./shell-security-section";
describe("ShellSecuritySection", () => {
it("is defined", () => {
expect(ShellSecuritySection).toBeDefined();
});
});
@@ -59,8 +59,12 @@ export function ShellSecuritySection({ data, onSave, saving }: Props) {
}, []);
const handleSave = async () => {
await onSave({ ...data, shellDenyGroups: draft });
setDirty(false);
try {
await onSave({ ...data, shellDenyGroups: draft });
setDirty(false);
} catch {
/* toast already shown by useConfig's patch() */
}
};
// Resolve effective state: draft override → group default.
@@ -44,7 +44,7 @@ export function TelemetrySection({ data, onSave, saving }: Props) {
};
const handleSave = () => {
onSave({ ...draft, headers: Object.keys(headers).length > 0 ? headers : undefined });
onSave({ ...draft, headers: Object.keys(headers).length > 0 ? headers : undefined }).catch(() => {});
};
if (!data) return null;
@@ -144,7 +144,7 @@ export function ToolsBrowserSection({ data, onSave, saving }: Props) {
{dirty && (
<div className="flex justify-end pt-2">
<Button size="sm" onClick={() => onSave(draft)} disabled={saving} className="gap-1.5">
<Button size="sm" onClick={() => { onSave(draft).catch(() => {}); }} disabled={saving} className="gap-1.5">
<Save className="h-3.5 w-3.5" /> {saving ? t("saving") : t("save")}
</Button>
</div>
@@ -152,7 +152,7 @@ export function ToolsExecSection({ data, onSave, saving }: Props) {
{dirty && (
<div className="flex justify-end pt-2">
<Button size="sm" onClick={() => onSave(draft)} disabled={saving || !!allowlistError} className="gap-1.5">
<Button size="sm" onClick={() => { onSave(draft).catch(() => {}); }} disabled={saving || !!allowlistError} className="gap-1.5">
<Save className="h-3.5 w-3.5" /> {saving ? t("saving") : t("save")}
</Button>
</div>
@@ -109,7 +109,7 @@ export function ToolsProfileSection({ data, onSave, saving }: Props) {
{dirty && (
<div className="flex justify-end pt-2">
<Button size="sm" onClick={() => onSave(draft)} disabled={saving} className="gap-1.5">
<Button size="sm" onClick={() => { onSave(draft).catch(() => {}); }} disabled={saving} className="gap-1.5">
<Save className="h-3.5 w-3.5" /> {saving ? t("saving") : t("save")}
</Button>
</div>