fix(boards): Cascade board deletion

Delete board-owned tasks, agents, and activity events before\nremoving the board. Adds board edit page with gateway settings\nand edit/delete actions in the boards table.\n\nCo-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Abhimanyu Saharan
2026-02-04 16:15:01 +05:30
parent d80f08e042
commit 14f7ea2aa2
4 changed files with 401 additions and 139 deletions

View File

@@ -1,7 +1,12 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
import asyncio
import re
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import delete
from sqlmodel import Session, col, select
from app.api.deps import (
ActorContext,
@@ -11,11 +16,59 @@ from app.api.deps import (
)
from app.core.auth import AuthContext
from app.db.session import get_session
from app.integrations.openclaw_gateway import (
GatewayConfig,
OpenClawGatewayError,
delete_session,
ensure_session,
send_message,
)
from app.models.activity_events import ActivityEvent
from app.models.agents import Agent
from app.models.boards import Board
from app.models.tasks import Task
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
router = APIRouter(prefix="/boards", tags=["boards"])
AGENT_SESSION_PREFIX = "agent"
def _slugify(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or uuid4().hex
def _build_session_key(agent_name: str) -> str:
return f"{AGENT_SESSION_PREFIX}:{_slugify(agent_name)}:main"
def _board_gateway_config(board: Board) -> GatewayConfig | None:
if not board.gateway_url:
return None
return GatewayConfig(url=board.gateway_url, token=board.gateway_token)
async def _cleanup_agent_on_gateway(agent: Agent, board: Board, config: GatewayConfig) -> None:
if agent.openclaw_session_id:
await delete_session(agent.openclaw_session_id, config=config)
main_session = board.gateway_main_session_key or "agent:main:main"
workspace_root = board.gateway_workspace_root or "~/.openclaw/workspaces"
workspace_path = f"{workspace_root.rstrip('/')}/{_slugify(agent.name)}"
cleanup_message = (
"Cleanup request for deleted agent.\n\n"
f"Agent name: {agent.name}\n"
f"Agent id: {agent.id}\n"
f"Session key: {agent.openclaw_session_id or _build_session_key(agent.name)}\n"
f"Workspace path: {workspace_path}\n\n"
"Actions:\n"
"1) Remove the workspace directory.\n"
"2) Delete any lingering session artifacts.\n"
"Reply NO_REPLY."
)
await ensure_session(main_session, config=config, label="Main Agent")
await send_message(cleanup_message, session_key=main_session, config=config, deliver=False)
@router.get("", response_model=list[BoardRead])
def list_boards(
@@ -73,6 +126,33 @@ def delete_board(
board: Board = Depends(get_board_or_404),
auth: AuthContext = Depends(require_admin_auth),
) -> dict[str, bool]:
agents = list(session.exec(select(Agent).where(Agent.board_id == board.id)))
task_ids = list(
session.exec(select(Task.id).where(Task.board_id == board.id))
)
config = _board_gateway_config(board)
if config:
try:
for agent in agents:
asyncio.run(_cleanup_agent_on_gateway(agent, board, config))
except OpenClawGatewayError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Gateway cleanup failed: {exc}",
) from exc
if task_ids:
session.execute(
delete(ActivityEvent).where(col(ActivityEvent.task_id).in_(task_ids))
)
if agents:
agent_ids = [agent.id for agent in agents]
session.execute(
delete(ActivityEvent).where(col(ActivityEvent.agent_id).in_(agent_ids))
)
session.execute(delete(Agent).where(col(Agent.id).in_(agent_ids)))
session.execute(delete(Task).where(col(Task.board_id) == board.id))
session.delete(board)
session.commit()
return {"ok": True}

View File

@@ -0,0 +1,233 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
"http://localhost:8000";
type Board = {
id: string;
name: string;
slug: string;
gateway_url?: string | null;
gateway_main_session_key?: string | null;
gateway_workspace_root?: string | null;
};
const slugify = (value: string) =>
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "") || "board";
export default function EditBoardPage() {
const { getToken, isSignedIn } = useAuth();
const router = useRouter();
const params = useParams();
const boardIdParam = params?.boardId;
const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam;
const [board, setBoard] = useState<Board | null>(null);
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [gatewayUrl, setGatewayUrl] = useState("");
const [gatewayToken, setGatewayToken] = useState("");
const [gatewayMainSessionKey, setGatewayMainSessionKey] = useState("");
const [gatewayWorkspaceRoot, setGatewayWorkspaceRoot] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadBoard = async () => {
if (!isSignedIn || !boardId) return;
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/boards/${boardId}`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
});
if (!response.ok) {
throw new Error("Unable to load board.");
}
const data = (await response.json()) as Board;
setBoard(data);
setName(data.name);
setSlug(data.slug);
setGatewayUrl(data.gateway_url ?? "");
setGatewayMainSessionKey(data.gateway_main_session_key ?? "");
setGatewayWorkspaceRoot(data.gateway_workspace_root ?? "");
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadBoard();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn, boardId]);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isSignedIn || !boardId) return;
const trimmed = name.trim();
if (!trimmed) {
setError("Board name is required.");
return;
}
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const payload: Partial<Board> & { gateway_token?: string | null } = {
name: trimmed,
slug: slug.trim() || slugify(trimmed),
gateway_url: gatewayUrl.trim() || null,
gateway_main_session_key: gatewayMainSessionKey.trim() || null,
gateway_workspace_root: gatewayWorkspaceRoot.trim() || null,
};
if (gatewayToken.trim()) {
payload.gateway_token = gatewayToken.trim();
}
const response = await fetch(`${apiBase}/api/v1/boards/${boardId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error("Unable to update board.");
}
router.push(`/boards/${boardId}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
};
return (
<DashboardShell>
<SignedOut>
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-2xl surface-panel p-10 text-center lg:col-span-2">
<p className="text-sm text-muted">Sign in to edit boards.</p>
<SignInButton
mode="modal"
forceRedirectUrl={`/boards/${boardId}/edit`}
signUpForceRedirectUrl={`/boards/${boardId}/edit`}
>
<Button>Sign in</Button>
</SignInButton>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<div className="flex h-full flex-col justify-center rounded-2xl surface-panel p-8">
<div className="mb-6 space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
Edit board
</p>
<h1 className="text-2xl font-semibold text-strong">
{board?.name ?? "Board"}
</h1>
<p className="text-sm text-muted">
Update the board identity and gateway connection.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-strong">Board name</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Product ops"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">Slug</label>
<Input
value={slug}
onChange={(event) => setSlug(event.target.value)}
placeholder="product-ops"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Gateway URL
</label>
<Input
value={gatewayUrl}
onChange={(event) => setGatewayUrl(event.target.value)}
placeholder="ws://gateway:18789"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Gateway token
</label>
<Input
value={gatewayToken}
onChange={(event) => setGatewayToken(event.target.value)}
placeholder="Leave blank to keep current token"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Main session key
</label>
<Input
value={gatewayMainSessionKey}
onChange={(event) => setGatewayMainSessionKey(event.target.value)}
placeholder="agent:main:main"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Workspace root
</label>
<Input
value={gatewayWorkspaceRoot}
onChange={(event) => setGatewayWorkspaceRoot(event.target.value)}
placeholder="~/.openclaw/workspaces"
disabled={isLoading}
/>
</div>
{error ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{error}
</div>
) : null}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Saving…" : "Save changes"}
</Button>
</form>
<Button
variant="outline"
className="mt-4"
onClick={() => router.push(`/boards/${boardId}`)}
>
Back to board
</Button>
</div>
</SignedIn>
</DashboardShell>
);
}

View File

@@ -31,10 +31,6 @@ type Board = {
id: string;
name: string;
slug: string;
gateway_url?: string | null;
gateway_token?: string | null;
gateway_main_session_key?: string | null;
gateway_workspace_root?: string | null;
};
type Task = {
@@ -74,13 +70,6 @@ export default function BoardDetailPage() {
const [priority, setPriority] = useState("medium");
const [createError, setCreateError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [gatewayUrl, setGatewayUrl] = useState("");
const [gatewayToken, setGatewayToken] = useState("");
const [gatewayMainSessionKey, setGatewayMainSessionKey] = useState("");
const [gatewayWorkspaceRoot, setGatewayWorkspaceRoot] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
const titleLabel = useMemo(
() => (board ? `${board.name} board` : "Board"),
@@ -117,9 +106,6 @@ export default function BoardDetailPage() {
const taskData = (await tasksResponse.json()) as Task[];
setBoard(boardData);
setTasks(taskData);
setGatewayUrl(boardData.gateway_url ?? "");
setGatewayMainSessionKey(boardData.gateway_main_session_key ?? "");
setGatewayWorkspaceRoot(boardData.gateway_workspace_root ?? "");
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
@@ -179,46 +165,6 @@ export default function BoardDetailPage() {
}
};
const handleSaveSettings = async () => {
if (!isSignedIn || !boardId) return;
setIsSaving(true);
setSaveError(null);
setSaveSuccess(false);
try {
const token = await getToken();
const payload: Partial<Board> = {
gateway_url: gatewayUrl.trim() || null,
gateway_main_session_key: gatewayMainSessionKey.trim() || null,
gateway_workspace_root: gatewayWorkspaceRoot.trim() || null,
};
if (gatewayToken.trim()) {
payload.gateway_token = gatewayToken.trim();
}
const response = await fetch(`${apiBase}/api/v1/boards/${boardId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error("Unable to update board settings.");
}
const updated = (await response.json()) as Board;
setBoard(updated);
setGatewayUrl(updated.gateway_url ?? "");
setGatewayMainSessionKey(updated.gateway_main_session_key ?? "");
setGatewayWorkspaceRoot(updated.gateway_workspace_root ?? "");
setGatewayToken("");
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2500);
} catch (err) {
setSaveError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsSaving(false);
}
};
return (
<DashboardShell>
@@ -268,88 +214,11 @@ export default function BoardDetailPage() {
Loading {titleLabel}
</div>
) : (
<>
<TaskBoard
tasks={tasks}
onCreateTask={() => setIsDialogOpen(true)}
isCreateDisabled={isCreating}
/>
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-6">
<div className="mb-4 space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
Gateway settings
</p>
<h2 className="text-lg font-semibold text-strong">
Connect this board to an OpenClaw gateway.
</h2>
<p className="text-sm text-muted">
Used when provisioning agents and checking gateway status for
this board.
</p>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Gateway URL
</label>
<Input
value={gatewayUrl}
onChange={(event) => setGatewayUrl(event.target.value)}
placeholder="ws://gateway:18789"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Gateway token
</label>
<Input
value={gatewayToken}
onChange={(event) => setGatewayToken(event.target.value)}
placeholder="Leave blank to keep current token"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Main session key
</label>
<Input
value={gatewayMainSessionKey}
onChange={(event) =>
setGatewayMainSessionKey(event.target.value)
}
placeholder="agent:main:main"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Workspace root
</label>
<Input
value={gatewayWorkspaceRoot}
onChange={(event) =>
setGatewayWorkspaceRoot(event.target.value)
}
placeholder="~/.openclaw/workspaces"
/>
</div>
</div>
{saveError ? (
<div className="mt-4 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{saveError}
</div>
) : null}
{saveSuccess ? (
<div className="mt-4 text-xs text-[color:var(--success)]">
Gateway settings saved.
</div>
) : null}
<div className="mt-4 flex justify-end">
<Button onClick={handleSaveSettings} disabled={isSaving}>
{isSaving ? "Saving…" : "Save settings"}
</Button>
</div>
</div>
</>
)}
</div>
</SignedIn>

View File

@@ -15,6 +15,14 @@ import {
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
type Board = {
id: string;
@@ -32,6 +40,9 @@ export default function BoardsPage() {
const [boards, setBoards] = useState<Board[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Board | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const sortedBoards = useMemo(
() => [...boards].sort((a, b) => a.name.localeCompare(b.name)),
@@ -66,6 +77,30 @@ export default function BoardsPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn]);
const handleDelete = async () => {
if (!deleteTarget || !isSignedIn) return;
setIsDeleting(true);
setDeleteError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/boards/${deleteTarget.id}`, {
method: "DELETE",
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
});
if (!response.ok) {
throw new Error("Unable to delete board.");
}
setBoards((prev) => prev.filter((board) => board.id !== deleteTarget.id));
setDeleteTarget(null);
} catch (err) {
setDeleteError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsDeleting(false);
}
};
const columns = useMemo<ColumnDef<Board>[]>(
() => [
{
@@ -83,7 +118,7 @@ export default function BoardsPage() {
header: "",
cell: ({ row }) => (
<div
className="flex items-center justify-end"
className="flex items-center justify-end gap-2"
onClick={(event) => event.stopPropagation()}
>
<Link
@@ -92,6 +127,19 @@ export default function BoardsPage() {
>
Open
</Link>
<Link
href={`/boards/${row.original.id}/edit`}
className="inline-flex h-8 items-center justify-center rounded-lg border border-[color:var(--border)] px-3 text-xs font-medium text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
>
Edit
</Link>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteTarget(row.original)}
>
Delete
</Button>
</div>
),
},
@@ -190,6 +238,38 @@ export default function BoardsPage() {
)}
</div>
</SignedIn>
<Dialog
open={!!deleteTarget}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setDeleteTarget(null);
setDeleteError(null);
}
}}
>
<DialogContent aria-label="Delete board">
<DialogHeader>
<DialogTitle>Delete board</DialogTitle>
<DialogDescription>
This will remove {deleteTarget?.name}. This action cannot be undone.
</DialogDescription>
</DialogHeader>
{deleteError ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{deleteError}
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DashboardShell>
);
}