mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
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:
1 parent
150c2b3c01
commit
6d24147a38
17 files changed
+87
-35
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user