From 10262f1e5e4ddc6209574aebe288335c689f18a4 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Mon, 13 Apr 2026 16:38:23 +0000 Subject: [PATCH 1/4] WIP: container eviction save --- services/gastown/src/dos/Town.do.ts | 28 +++++ services/gastown/src/dos/town/beads.ts | 138 +++++++++++++++++++++++++ services/gastown/src/trpc/router.ts | 61 ++++++++++- 3 files changed, 225 insertions(+), 2 deletions(-) diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index b7d0e475d4..1fcac8974e 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -1134,6 +1134,34 @@ export class TownDO extends DurableObject { beadOps.deleteBead(this.sql, beadId); } + async deleteBeads(beadIds: string[]): Promise { + return beadOps.deleteBeads(this.sql, beadIds); + } + + async deleteBeadsByStatus( + status: BeadStatusType, + type?: BeadTypeType, + rigId?: string + ): Promise { + if (rigId) { + const rigBeads = BeadRecord.pick({ bead_id: true }) + .array() + .parse([ + ...query( + this.sql, + /* sql */ `SELECT ${beads.bead_id} FROM ${beads} WHERE ${beads.rig_id} = ? AND ${beads.status} = ?${type ? ` AND ${beads.type} = ?` : ''}`, + type ? [rigId, status, type] : [rigId, status] + ), + ]); + if (rigBeads.length === 0) return 0; + return beadOps.deleteBeads( + this.sql, + rigBeads.map(r => r.bead_id) + ); + } + return beadOps.deleteBeadsByStatus(this.sql, status, type); + } + async listBeadEvents(options: { beadId?: string; since?: string; diff --git a/services/gastown/src/dos/town/beads.ts b/services/gastown/src/dos/town/beads.ts index 1f1409c337..efb2f8027e 100644 --- a/services/gastown/src/dos/town/beads.ts +++ b/services/gastown/src/dos/town/beads.ts @@ -715,6 +715,144 @@ export function deleteBead(sql: SqlStorage, beadId: string): void { query(sql, /* sql */ `DELETE FROM ${beads} WHERE ${beads.bead_id} = ?`, [beadId]); } +export function deleteBeads(sql: SqlStorage, beadIds: string[]): number { + if (beadIds.length === 0) return 0; + + const allIds = new Set(beadIds); + + // Expand with child beads (molecule steps, etc.) + const childRows = [ + ...query( + sql, + /* sql */ `SELECT ${beads.bead_id} FROM ${beads} WHERE ${beads.parent_bead_id} IN (${beadIds.map(() => '?').join(',')})`, + [...beadIds] + ), + ]; + const childIds = BeadRecord.pick({ bead_id: true }) + .array() + .parse(childRows) + .map(r => r.bead_id); + + // Recursively collect children of children + if (childIds.length > 0) { + for (const childId of childIds) { + allIds.add(childId); + } + // Recurse for deeper nesting + const deeperIds = collectChildBeadIds(sql, childIds); + for (const id of deeperIds) { + allIds.add(id); + } + } + + const allIdsArr = [...allIds]; + const placeholders = allIdsArr.map(() => '?').join(','); + + // Unhook agents assigned to any of these beads + query( + sql, + /* sql */ ` + UPDATE ${agent_metadata} + SET ${agent_metadata.columns.current_hook_bead_id} = NULL, + ${agent_metadata.columns.status} = 'idle' + WHERE ${agent_metadata.current_hook_bead_id} IN (${placeholders}) + `, + [...allIdsArr] + ); + + // Delete dependencies referencing any of these beads + query( + sql, + /* sql */ `DELETE FROM ${bead_dependencies} WHERE ${bead_dependencies.bead_id} IN (${placeholders}) OR ${bead_dependencies.depends_on_bead_id} IN (${placeholders})`, + [...allIdsArr, ...allIdsArr] + ); + + // Delete events + query( + sql, + /* sql */ `DELETE FROM ${bead_events} WHERE ${bead_events.bead_id} IN (${placeholders})`, + [...allIdsArr] + ); + + // Delete satellite metadata + query( + sql, + /* sql */ `DELETE FROM ${agent_metadata} WHERE ${agent_metadata.bead_id} IN (${placeholders})`, + [...allIdsArr] + ); + query( + sql, + /* sql */ `DELETE FROM ${review_metadata} WHERE ${review_metadata.bead_id} IN (${placeholders})`, + [...allIdsArr] + ); + query( + sql, + /* sql */ `DELETE FROM ${escalation_metadata} WHERE ${escalation_metadata.bead_id} IN (${placeholders})`, + [...allIdsArr] + ); + query( + sql, + /* sql */ `DELETE FROM ${convoy_metadata} WHERE ${convoy_metadata.bead_id} IN (${placeholders})`, + [...allIdsArr] + ); + + // Delete the beads themselves + query( + sql, + /* sql */ `DELETE FROM ${beads} WHERE ${beads.bead_id} IN (${placeholders})`, + [...allIdsArr] + ); + + return allIdsArr.length; +} + +function collectChildBeadIds(sql: SqlStorage, parentIds: string[]): string[] { + if (parentIds.length === 0) return []; + const childRows = [ + ...query( + sql, + /* sql */ `SELECT ${beads.bead_id} FROM ${beads} WHERE ${beads.parent_bead_id} IN (${parentIds.map(() => '?').join(',')})`, + [...parentIds] + ), + ]; + const childIds = BeadRecord.pick({ bead_id: true }) + .array() + .parse(childRows) + .map(r => r.bead_id); + if (childIds.length === 0) return []; + const deeperIds = collectChildBeadIds(sql, childIds); + return [...childIds, ...deeperIds]; +} + +export function deleteBeadsByStatus( + sql: SqlStorage, + status: BeadStatus, + type?: BeadType +): number { + const conditions: string[] = [`${beads.status} = ?`]; + const values: unknown[] = [status]; + + if (type) { + conditions.push(`${beads.type} = ?`); + values.push(type); + } + + const rows = [ + ...query( + sql, + /* sql */ `SELECT ${beads.bead_id} FROM ${beads} WHERE ${conditions.join(' AND ')}`, + values + ), + ]; + const beadIds = BeadRecord.pick({ bead_id: true }) + .array() + .parse(rows) + .map(r => r.bead_id); + + if (beadIds.length === 0) return 0; + return deleteBeads(sql, beadIds); +} + // ── Bead Events ───────────────────────────────────────────────────── export function logBeadEvent( diff --git a/services/gastown/src/trpc/router.ts b/services/gastown/src/trpc/router.ts index 12aab73298..603691b770 100644 --- a/services/gastown/src/trpc/router.ts +++ b/services/gastown/src/trpc/router.ts @@ -650,14 +650,21 @@ export const gastownRouter = router({ .input( z.object({ rigId: z.string().uuid(), - beadId: z.string().uuid(), + beadId: z.union([z.string().uuid(), z.array(z.string().uuid())]), townId: z.string().uuid().optional(), }) ) + .output(z.object({ deleted: z.number() })) .mutation(async ({ ctx, input }) => { const rig = await verifyRigOwnership(ctx.env, ctx, input.rigId, input.townId); const townStub = getTownDOStub(ctx.env, rig.town_id); - await townStub.deleteBead(input.beadId); + const ids = Array.isArray(input.beadId) ? input.beadId : [input.beadId]; + if (ids.length === 1) { + await townStub.deleteBead(ids[0]); + return { deleted: 1 }; + } + const count = await townStub.deleteBeads(ids); + return { deleted: count }; }), updateBead: gastownProcedure @@ -707,6 +714,25 @@ export const gastownRouter = router({ return townStub.updateBead(beadId, fields, ctx.userId); }), + deleteBeadsByStatus: gastownProcedure + .input( + z.object({ + rigId: z.string().uuid(), + status: z.enum(['open', 'in_progress', 'in_review', 'closed', 'failed']), + type: z + .enum(['issue', 'message', 'escalation', 'merge_request', 'convoy', 'molecule', 'agent']) + .optional(), + townId: z.string().uuid().optional(), + }) + ) + .output(z.object({ deleted: z.number() })) + .mutation(async ({ ctx, input }) => { + const rig = await verifyRigOwnership(ctx.env, ctx, input.rigId, input.townId); + const townStub = getTownDOStub(ctx.env, rig.town_id); + const count = await townStub.deleteBeadsByStatus(input.status, input.type, rig.id); + return { deleted: count }; + }), + // ── Agents ────────────────────────────────────────────────────────── listAgents: gastownProcedure @@ -1592,6 +1618,37 @@ export const gastownRouter = router({ return townStub.getBeadAsync(input.beadId); }), + adminBulkDeleteBeads: adminProcedure + .input( + z.object({ + townId: z.string().uuid(), + beadIds: z.array(z.string().uuid()), + }) + ) + .output(z.object({ deleted: z.number() })) + .mutation(async ({ ctx, input }) => { + const townStub = getTownDOStub(ctx.env, input.townId); + const count = await townStub.deleteBeads(input.beadIds); + return { deleted: count }; + }), + + adminDeleteBeadsByStatus: adminProcedure + .input( + z.object({ + townId: z.string().uuid(), + status: z.enum(['open', 'in_progress', 'in_review', 'closed', 'failed']), + type: z + .enum(['issue', 'message', 'escalation', 'merge_request', 'convoy', 'molecule', 'agent']) + .optional(), + }) + ) + .output(z.object({ deleted: z.number() })) + .mutation(async ({ ctx, input }) => { + const townStub = getTownDOStub(ctx.env, input.townId); + const count = await townStub.deleteBeadsByStatus(input.status, input.type); + return { deleted: count }; + }), + // DEBUG: raw agent_metadata dump — remove after debugging debugAgentMetadata: adminProcedure .input(z.object({ townId: z.string().uuid() })) From 657beafd400fad6349342c52ab1ebe70b9686942 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Mon, 13 Apr 2026 16:54:14 +0000 Subject: [PATCH 2/4] feat(gastown): add bulk delete and delete-by-status mayor API endpoints Add POST routes for bulk-delete and delete-by-status operations on beads, with corresponding handler functions that validate rig ownership and delegate to TownDO methods. --- services/gastown/src/gastown.worker.ts | 12 ++++ .../src/handlers/mayor-tools.handler.ts | 63 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/services/gastown/src/gastown.worker.ts b/services/gastown/src/gastown.worker.ts index 2e918ea215..6f3b3aae6c 100644 --- a/services/gastown/src/gastown.worker.ts +++ b/services/gastown/src/gastown.worker.ts @@ -113,6 +113,8 @@ import { handleMayorConvoyClose, handleMayorConvoyUpdate, handleMayorBeadDelete, + handleMayorBulkDeleteBeads, + handleMayorDeleteBeadsByStatus, handleMayorEscalationAcknowledge, handleMayorConvoyStart, handleMayorUiAction, @@ -978,6 +980,16 @@ app.delete('/api/mayor/:townId/tools/rigs/:rigId/beads/:beadId', c => handleMayorBeadDelete(c, c.req.param()) ) ); +app.post('/api/mayor/:townId/tools/rigs/:rigId/beads/bulk-delete', c => + instrumented(c, 'POST /api/mayor/:townId/tools/rigs/:rigId/beads/bulk-delete', () => + handleMayorBulkDeleteBeads(c, c.req.param()) + ) +); +app.post('/api/mayor/:townId/tools/rigs/:rigId/beads/delete-by-status', c => + instrumented(c, 'POST /api/mayor/:townId/tools/rigs/:rigId/beads/delete-by-status', () => + handleMayorDeleteBeadsByStatus(c, c.req.param()) + ) +); app.post('/api/mayor/:townId/tools/rigs/:rigId/agents/:agentId/reset', c => instrumented(c, 'POST /api/mayor/:townId/tools/rigs/:rigId/agents/:agentId/reset', () => handleMayorAgentReset(c, c.req.param()) diff --git a/services/gastown/src/handlers/mayor-tools.handler.ts b/services/gastown/src/handlers/mayor-tools.handler.ts index fcfe207ce3..852bf96391 100644 --- a/services/gastown/src/handlers/mayor-tools.handler.ts +++ b/services/gastown/src/handlers/mayor-tools.handler.ts @@ -635,6 +635,69 @@ export async function handleMayorBeadDelete( return c.json(resSuccess({ deleted: true })); } +const MayorBulkDeleteBeadsBody = z.object({ + bead_ids: z.array(z.string().uuid()).min(1).max(5000), +}); + +export async function handleMayorBulkDeleteBeads( + c: Context, + params: { townId: string; rigId: string } +) { + const rigOwned = await verifyRigBelongsToTown(c, params.townId, params.rigId); + if (!rigOwned) { + return c.json(resError('Rig not found in this town'), 403); + } + + const parsed = await parseJsonBody(c, MayorBulkDeleteBeadsBody); + if (!parsed.success) { + return c.json(resError('Invalid request body', parsed.error), 400); + } + + const { bead_ids } = parsed.data; + + console.log( + `${HANDLER_LOG} handleMayorBulkDeleteBeads: townId=${params.townId} rigId=${params.rigId} count=${bead_ids.length}` + ); + + const town = getTownDOStub(c.env, params.townId); + const count = await town.deleteBeads(bead_ids); + + return c.json(resSuccess({ deleted: count })); +} + +const MayorDeleteBeadsByStatusBody = z.object({ + status: z.enum(['open', 'in_progress', 'in_review', 'closed', 'failed']), + type: z + .enum(['issue', 'message', 'escalation', 'merge_request', 'convoy', 'molecule', 'agent']) + .optional(), +}); + +export async function handleMayorDeleteBeadsByStatus( + c: Context, + params: { townId: string; rigId: string } +) { + const rigOwned = await verifyRigBelongsToTown(c, params.townId, params.rigId); + if (!rigOwned) { + return c.json(resError('Rig not found in this town'), 403); + } + + const parsed = await parseJsonBody(c, MayorDeleteBeadsByStatusBody); + if (!parsed.success) { + return c.json(resError('Invalid request body', parsed.error), 400); + } + + const { status, type } = parsed.data; + + console.log( + `${HANDLER_LOG} handleMayorDeleteBeadsByStatus: townId=${params.townId} rigId=${params.rigId} status=${status}${type ? ` type=${type}` : ''}` + ); + + const town = getTownDOStub(c.env, params.townId); + const count = await town.deleteBeadsByStatus(status, type, params.rigId); + + return c.json(resSuccess({ deleted: count })); +} + /** * POST /api/mayor/:townId/tools/escalations/:escalationId/acknowledge * Acknowledge an escalation, marking it as reviewed. From e694a1e54bea2f4d80348cbb546c6b4d8b72f560 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 18 Apr 2026 02:35:13 +0000 Subject: [PATCH 3/4] feat(gastown): add bulk bead deletion UI and bulk delete endpoints - Update deleteBead tRPC mutation to accept beadId: string | string[] - Add deleteBeadsByStatus tRPC mutation for bulk delete by status - Add adminBulkDeleteBeads and adminDeleteBeadsByStatus admin mutations - Add bulk delete methods to gastown container plugin client.ts - Update gt_bead_delete mayor tool to accept single ID or array - Add checkbox multi-select + bulk action bar to BeadsPageClient - Add 'Delete all failed (N)' button on Beads page - Add checkbox multi-select + bulk delete to admin BeadsTab - Add bulk delete and delete-by-status admin mutations to gastown-router.ts --- .../[townId]/beads/BeadsPageClient.tsx | 214 +++++++++++++++++- .../admin/gastown/towns/[townId]/BeadsTab.tsx | 181 +++++++++++++-- apps/web/src/routers/admin/gastown-router.ts | 34 +++ pnpm-lock.yaml | 58 ++--- services/gastown/container/plugin/client.ts | 24 ++ .../gastown/container/plugin/mayor-tools.ts | 18 +- .../src/prompts/mayor-system.prompt.ts | 2 +- 7 files changed, 450 insertions(+), 81 deletions(-) diff --git a/apps/web/src/app/(app)/gastown/[townId]/beads/BeadsPageClient.tsx b/apps/web/src/app/(app)/gastown/[townId]/beads/BeadsPageClient.tsx index 6c85cd9744..c2b1f8304e 100644 --- a/apps/web/src/app/(app)/gastown/[townId]/beads/BeadsPageClient.tsx +++ b/apps/web/src/app/(app)/gastown/[townId]/beads/BeadsPageClient.tsx @@ -1,14 +1,23 @@ 'use client'; -import { useState, useMemo } from 'react'; -import { useQuery, useQueries } from '@tanstack/react-query'; +import { useState, useMemo, useCallback } from 'react'; +import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query'; import { useGastownTRPC } from '@/lib/gastown/trpc'; import { useDrawerStack } from '@/components/gastown/DrawerStack'; -import { Hexagon, Search } from 'lucide-react'; +import { Hexagon, Search, Trash2, X } from 'lucide-react'; import { SidebarTrigger } from '@/components/ui/sidebar'; import { formatDistanceToNow } from 'date-fns'; import { motion, AnimatePresence } from 'motion/react'; import type { GastownOutputs } from '@/lib/gastown/trpc'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; type Bead = GastownOutputs['gastown']['listBeads'][number]; @@ -23,11 +32,18 @@ const STATUS_DOT: Record = { failed: 'bg-red-400', }; +type DeleteConfirm = + | { kind: 'selected'; ids: string[]; rigId: string } + | { kind: 'all-failed'; count: number; rigIds: string[] }; + export function BeadsPageClient({ townId }: BeadsPageClientProps) { const trpc = useGastownTRPC(); + const queryClient = useQueryClient(); const { open: openDrawer } = useDrawerStack(); const [statusFilter, setStatusFilter] = useState(null); const [search, setSearch] = useState(''); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [deleteConfirm, setDeleteConfirm] = useState(null); const rigsQuery = useQuery(trpc.gastown.listRigs.queryOptions({ townId })); const rigs = rigsQuery.data ?? []; @@ -78,8 +94,92 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) { return counts; }, [allBeads]); + const failedBeads = useMemo(() => allBeads.filter(b => b.status === 'failed'), [allBeads]); + const isLoading = rigsQuery.isLoading || rigBeadQueries.some(q => q.isLoading); + const invalidateBeads = useCallback(() => { + for (const rig of rigs) { + void queryClient.invalidateQueries(trpc.gastown.listBeads.queryFilter({ rigId: rig.id })); + } + }, [queryClient, rigs, trpc.gastown.listBeads]); + + const deleteBeadMutation = useMutation( + trpc.gastown.deleteBead.mutationOptions({ + onSuccess: () => { + invalidateBeads(); + setSelectedIds(new Set()); + setDeleteConfirm(null); + }, + }) + ); + + const isDeleting = deleteBeadMutation.isPending; + + // Build a map from bead_id -> rigId for lookups + const beadRigMap = useMemo(() => { + const map = new Map(); + for (const bead of allBeads) { + map.set(bead.bead_id, bead.rigId); + } + return map; + }, [allBeads]); + + const allFilteredSelected = + filteredBeads.length > 0 && filteredBeads.every(b => selectedIds.has(b.bead_id)); + + const toggleSelectAll = () => { + if (allFilteredSelected) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(filteredBeads.map(b => b.bead_id))); + } + }; + + const toggleSelect = (beadId: string) => { + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(beadId)) { + next.delete(beadId); + } else { + next.add(beadId); + } + return next; + }); + }; + + const handleDeleteSelected = () => { + if (selectedIds.size === 0) return; + // Group by rigId — pick the first rig for simplicity (all selected beads share the same rig + // in most cases; if mixed, we use the first one and the mutation handles array input) + const selectedArr = [...selectedIds]; + const firstRigId = beadRigMap.get(selectedArr[0] ?? '') ?? ''; + setDeleteConfirm({ kind: 'selected', ids: selectedArr, rigId: firstRigId }); + }; + + const handleDeleteAllFailed = () => { + if (failedBeads.length === 0) return; + const rigIds = [...new Set(failedBeads.map(b => b.rigId))]; + setDeleteConfirm({ kind: 'all-failed', count: failedBeads.length, rigIds }); + }; + + const handleConfirmDelete = () => { + if (!deleteConfirm) return; + + if (deleteConfirm.kind === 'selected') { + const { ids, rigId } = deleteConfirm; + deleteBeadMutation.mutate({ rigId, beadId: ids, townId }); + } else { + // Delete all failed beads — collect all IDs and bulk-delete via the array endpoint. + // Using the first rig's ID for ownership verification; the town DO deletes by IDs. + const { rigIds } = deleteConfirm; + const firstRigId = rigIds[0]; + if (!firstRigId) return; + const failedIds = failedBeads.map(b => b.bead_id); + deleteBeadMutation.mutate({ rigId: firstRigId, beadId: failedIds, townId }); + } + }; + return (
{/* Header */} @@ -90,6 +190,17 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) {

Beads

{allBeads.length}
+ + {/* Delete all failed shortcut */} + {failedBeads.length > 0 && ( + + )} {/* Filter bar */} @@ -127,6 +238,39 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) { + {/* Bulk action bar */} + + {selectedIds.size > 0 && ( + +
+ + {selectedIds.size} selected + + + +
+
+ )} +
+ {/* Bead list */}
{isLoading && ( @@ -153,6 +297,20 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) {
)} + {/* Select-all header row */} + {!isLoading && filteredBeads.length > 0 && ( +
+ + Select all ({filteredBeads.length}) +
+ )} + {filteredBeads.map((bead, i) => ( { - const rigId = (bead as Bead & { rigId: string }).rigId; - openDrawer({ type: 'bead', beadId: bead.bead_id, rigId }); - }} - className="group flex cursor-pointer items-center gap-3 border-b border-white/[0.04] px-6 py-2.5 transition-colors hover:bg-white/[0.02]" + className={`group flex cursor-pointer items-center gap-3 border-b border-white/[0.04] px-6 py-2.5 transition-colors hover:bg-white/[0.02] ${ + selectedIds.has(bead.bead_id) ? 'bg-white/[0.03]' : '' + }`} > + {/* Checkbox — stop propagation so clicking it doesn't open drawer */} + toggleSelect(bead.bead_id)} + onClick={e => e.stopPropagation()} + className="size-3.5 shrink-0 cursor-pointer accent-[oklch(95%_0.15_108)]" + aria-label={`Select bead ${bead.bead_id}`} + /> -
+
{ + openDrawer({ type: 'bead', beadId: bead.bead_id, rigId: bead.rigId }); + }} + >
{bead.title} @@ -180,7 +350,7 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) {
{bead.bead_id.slice(0, 8)} | - {(bead as Bead & { rigName: string }).rigName} + {bead.rigName} | {formatDistanceToNow(new Date(bead.created_at), { addSuffix: true })}
@@ -191,6 +361,30 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) {
+ {/* Delete confirmation dialog */} + { if (!open) setDeleteConfirm(null); }}> + + + + {deleteConfirm?.kind === 'all-failed' ? 'Delete all failed beads' : 'Delete beads'} + + + {deleteConfirm?.kind === 'all-failed' + ? `Delete ${deleteConfirm.count} failed bead${deleteConfirm.count === 1 ? '' : 's'}? This cannot be undone.` + : `Delete ${deleteConfirm?.ids.length ?? 0} selected bead${(deleteConfirm?.ids.length ?? 0) === 1 ? '' : 's'}? This cannot be undone.`} + + + + + + + + + {/* Drawers are rendered by the layout-level DrawerStackProvider */}
); diff --git a/apps/web/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx b/apps/web/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx index 2c071e4aa3..c10084ea42 100644 --- a/apps/web/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx +++ b/apps/web/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx @@ -22,8 +22,10 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; import Link from 'next/link'; import { formatDistanceToNow } from 'date-fns'; +import { Trash2 } from 'lucide-react'; const beadStatuses = ['open', 'in_progress', 'closed', 'failed'] as const; type BeadStatus = (typeof beadStatuses)[number]; @@ -46,11 +48,10 @@ const STATUS_COLORS: Record = { failed: 'bg-red-500/10 text-red-400 border-red-500/20', }; -type ConfirmAction = { - type: 'close' | 'fail'; - beadId: string; - title: string; -}; +type ConfirmAction = + | { type: 'close' | 'fail'; beadId: string; title: string } + | { type: 'bulk-delete'; beadIds: string[] } + | { type: 'delete-all-failed'; count: number }; export function BeadsTab({ townId }: { townId: string }) { const trpc = useTRPC(); @@ -59,6 +60,7 @@ export function BeadsTab({ townId }: { townId: string }) { const [statusFilter, setStatusFilter] = useState('all'); const [typeFilter, setTypeFilter] = useState('all'); const [confirmAction, setConfirmAction] = useState(null); + const [selectedIds, setSelectedIds] = useState>(new Set()); const beadsQuery = useQuery( trpc.admin.gastown.listBeads.queryOptions({ @@ -68,10 +70,14 @@ export function BeadsTab({ townId }: { townId: string }) { }) ); + const invalidateBeads = () => { + void queryClient.invalidateQueries(trpc.admin.gastown.listBeads.queryFilter({ townId })); + }; + const forceCloseMutation = useMutation( trpc.admin.gastown.forceCloseBead.mutationOptions({ onSuccess: () => { - void queryClient.invalidateQueries(trpc.admin.gastown.listBeads.queryFilter({ townId })); + invalidateBeads(); setConfirmAction(null); toast.success('Bead closed successfully'); }, @@ -84,7 +90,7 @@ export function BeadsTab({ townId }: { townId: string }) { const forceFailMutation = useMutation( trpc.admin.gastown.forceFailBead.mutationOptions({ onSuccess: () => { - void queryClient.invalidateQueries(trpc.admin.gastown.listBeads.queryFilter({ townId })); + invalidateBeads(); setConfirmAction(null); toast.success('Bead marked as failed'); }, @@ -94,17 +100,104 @@ export function BeadsTab({ townId }: { townId: string }) { }) ); + const bulkDeleteMutation = useMutation( + trpc.admin.gastown.bulkDeleteBeads.mutationOptions({ + onSuccess: data => { + invalidateBeads(); + setConfirmAction(null); + setSelectedIds(new Set()); + toast.success(`Deleted ${data.deleted} bead${data.deleted === 1 ? '' : 's'}`); + }, + onError: err => { + toast.error(`Failed to delete beads: ${err.message}`); + }, + }) + ); + + const deleteByStatusMutation = useMutation( + trpc.admin.gastown.deleteBeadsByStatus.mutationOptions({ + onSuccess: data => { + invalidateBeads(); + setConfirmAction(null); + setSelectedIds(new Set()); + toast.success(`Deleted ${data.deleted} bead${data.deleted === 1 ? '' : 's'}`); + }, + onError: err => { + toast.error(`Failed to delete beads: ${err.message}`); + }, + }) + ); + const handleConfirm = () => { if (!confirmAction) return; if (confirmAction.type === 'close') { forceCloseMutation.mutate({ townId, beadId: confirmAction.beadId }); - } else { + } else if (confirmAction.type === 'fail') { forceFailMutation.mutate({ townId, beadId: confirmAction.beadId }); + } else if (confirmAction.type === 'bulk-delete') { + bulkDeleteMutation.mutate({ townId, beadIds: confirmAction.beadIds }); + } else { + deleteByStatusMutation.mutate({ townId, status: 'failed' }); } }; const beads = beadsQuery.data ?? []; - const isPending = forceCloseMutation.isPending || forceFailMutation.isPending; + const failedCount = beads.filter(b => b.status === 'failed').length; + + const allSelected = beads.length > 0 && beads.every(b => selectedIds.has(b.bead_id)); + + const toggleSelectAll = () => { + if (allSelected) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(beads.map(b => b.bead_id))); + } + }; + + const toggleSelect = (beadId: string) => { + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(beadId)) { + next.delete(beadId); + } else { + next.add(beadId); + } + return next; + }); + }; + + const isPending = + forceCloseMutation.isPending || + forceFailMutation.isPending || + bulkDeleteMutation.isPending || + deleteByStatusMutation.isPending; + + const confirmDialogTitle = () => { + if (!confirmAction) return ''; + if (confirmAction.type === 'close') return 'Force Close Bead'; + if (confirmAction.type === 'fail') return 'Force Fail Bead'; + if (confirmAction.type === 'bulk-delete') return 'Delete Beads'; + return 'Delete All Failed Beads'; + }; + + const confirmDialogDescription = () => { + if (!confirmAction) return ''; + if (confirmAction.type === 'close') { + return `This will force-close bead ${confirmAction.beadId.slice(0, 8)}…${confirmAction.title ? ` (${confirmAction.title})` : ''}. This action is logged in the audit trail.`; + } + if (confirmAction.type === 'fail') { + return `This will force-fail bead ${confirmAction.beadId.slice(0, 8)}…${confirmAction.title ? ` (${confirmAction.title})` : ''}. This action is logged in the audit trail.`; + } + if (confirmAction.type === 'bulk-delete') { + return `Delete ${confirmAction.beadIds.length} selected bead${confirmAction.beadIds.length === 1 ? '' : 's'}? This cannot be undone.`; + } + return `Delete ${confirmAction.count} failed bead${confirmAction.count === 1 ? '' : 's'}? This cannot be undone.`; + }; + + const isDestructiveConfirm = + confirmAction?.type === 'fail' || + confirmAction?.type === 'bulk-delete' || + confirmAction?.type === 'delete-all-failed'; return ( @@ -112,6 +205,19 @@ export function BeadsTab({ townId }: { townId: string }) {
Beads
+ {/* Delete all failed shortcut */} + {failedCount > 0 && ( + + )} +
+ + {/* Bulk action bar */} + {selectedIds.size > 0 && ( +
+ {selectedIds.size} selected + + +
+ )} {beadsQuery.isLoading && ( @@ -174,6 +306,13 @@ export function BeadsTab({ townId }: { townId: string }) { + @@ -185,6 +324,13 @@ export function BeadsTab({ townId }: { townId: string }) { {beads.map(bead => ( +
+ + Bead Type Status
+ toggleSelect(bead.bead_id)} + aria-label={`Select bead ${bead.bead_id}`} + /> + setConfirmAction(null)}> - - {confirmAction?.type === 'close' ? 'Force Close Bead' : 'Force Fail Bead'} - - - This will {confirmAction?.type === 'close' ? 'force-close' : 'force-fail'} bead{' '} - {confirmAction?.beadId.slice(0, 8)}… - {confirmAction?.title ? ` (${confirmAction.title})` : ''}. This action is logged in - the audit trail. - + {confirmDialogTitle()} + {confirmDialogDescription()} diff --git a/apps/web/src/routers/admin/gastown-router.ts b/apps/web/src/routers/admin/gastown-router.ts index 43dc72def8..eae3aa209f 100644 --- a/apps/web/src/routers/admin/gastown-router.ts +++ b/apps/web/src/routers/admin/gastown-router.ts @@ -755,6 +755,40 @@ export const adminGastownRouter = createTRPCRouter({ ); }), + bulkDeleteBeads: adminProcedure + .input(z.object({ townId: z.string().uuid(), beadIds: z.array(z.string().uuid()) })) + .output(z.object({ deleted: z.number() })) + .mutation(async ({ input, ctx }) => { + const result = await gastownTrpcMutate( + ctx.user, + 'gastown.adminBulkDeleteBeads', + { townId: input.townId, beadIds: input.beadIds }, + z.object({ deleted: z.number() }) + ); + return result ?? { deleted: 0 }; + }), + + deleteBeadsByStatus: adminProcedure + .input( + z.object({ + townId: z.string().uuid(), + status: z.enum(['open', 'in_progress', 'in_review', 'closed', 'failed']), + type: z + .enum(['issue', 'message', 'escalation', 'merge_request', 'convoy', 'molecule', 'agent']) + .optional(), + }) + ) + .output(z.object({ deleted: z.number() })) + .mutation(async ({ input, ctx }) => { + const result = await gastownTrpcMutate( + ctx.user, + 'gastown.adminDeleteBeadsByStatus', + { townId: input.townId, status: input.status, type: input.type }, + z.object({ deleted: z.number() }) + ); + return result ?? { deleted: 0 }; + }), + /** Force-retry a stalled review queue entry. Not yet implemented on the worker. */ forceRetryReview: adminProcedure .input(z.object({ townId: z.string().uuid(), entryId: z.string().uuid() })) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a94c16a3a4..f052c7cc70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1445,7 +1445,7 @@ importers: version: 7.0.0-dev.20260319.1 jest: specifier: ^30.3.0 - version: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) + version: 30.3.0(@types/node@24.12.0)(esbuild-register@3.6.0(esbuild@0.27.4)) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1529,11 +1529,11 @@ importers: services/gastown/container: dependencies: '@kilocode/plugin': - specifier: 7.2.7 - version: 7.2.7 + specifier: 7.2.14 + version: 7.2.14 '@kilocode/sdk': - specifier: 7.2.7 - version: 7.2.7 + specifier: 7.2.14 + version: 7.2.14 hono: specifier: ^4.12.7 version: 4.12.8 @@ -4063,8 +4063,8 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@kilocode/plugin@7.2.7': - resolution: {integrity: sha512-m4fHQlrUjuZTEABtOUIoHfUW3ejPpF8Jv2VhkFYVKJmuerltQBBFb4udrN97GJHMyoLL3nzJ6bNZkg3Czv8Z3g==} + '@kilocode/plugin@7.2.14': + resolution: {integrity: sha512-mS+WA9HZIBH2qQ9ARA+v0q4MdQTSdfOvKbe4AOSkjP+P5hVA70OM/UVM9DVcvmjSOxU+wuUxmOy+j/EQIrgFmw==} peerDependencies: '@opentui/core': '>=0.1.97' '@opentui/solid': '>=0.1.97' @@ -4077,8 +4077,8 @@ packages: '@kilocode/sdk@7.1.17': resolution: {integrity: sha512-OMe11pt8m72rUIOrgUn9OEwSl+KlQzOlGD3zDyFutHqxylqXOr0/9IfYtAP5F5fUWYCtYE7SJbXcEbWvIyVhTg==} - '@kilocode/sdk@7.2.7': - resolution: {integrity: sha512-710n8PQ3QfmTwEdzOW2p7ur79rF9IBJk6nGsUu1hp8o/66RTkpVYC0EB7DKNfJ1NzTWmUqmhJZGWq4suCfADKQ==} + '@kilocode/sdk@7.2.14': + resolution: {integrity: sha512-Naz83lFrsbavuDp6UwxRuglOaSNvRBsZfcRNvb7RpWYAwbuJP0dBdhpXj6uO3ta5qxeQ2JzxKNC9Ffz+LCLLDg==} '@lottiefiles/dotlottie-react@0.17.15': resolution: {integrity: sha512-4wYAjsJhM28eUvJ/gT3KRM6fcyT7EM9n7PDrP71LaBTacc6bSN43qFTSJc1Li3QxUiraz23p0Q8EJBzXo8DsRw==} @@ -17553,14 +17553,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@kilocode/plugin@7.2.7': + '@kilocode/plugin@7.2.14': dependencies: - '@kilocode/sdk': 7.2.7 + '@kilocode/sdk': 7.2.14 zod: 4.1.8 '@kilocode/sdk@7.1.17': {} - '@kilocode/sdk@7.2.7': + '@kilocode/sdk@7.2.14': dependencies: cross-spawn: 7.0.6 @@ -21854,7 +21854,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/utils@3.2.4': dependencies: @@ -25224,25 +25224,6 @@ snapshots: - supports-color - ts-node - jest-cli@30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)): - dependencies: - '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) - '@jest/test-result': 30.3.0 - '@jest/types': 30.3.0 - chalk: 4.1.2 - exit-x: 0.2.2 - import-local: 3.2.0 - jest-config: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) - jest-util: 30.3.0 - jest-validate: 30.3.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jest-config@29.7.0(@types/node@24.12.0): dependencies: '@babel/core': 7.29.0 @@ -25894,19 +25875,6 @@ snapshots: - supports-color - ts-node - jest@30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)): - dependencies: - '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) - '@jest/types': 30.3.0 - import-local: 3.2.0 - jest-cli: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jimp-compact@0.16.1: {} jiti@2.6.1: {} diff --git a/services/gastown/container/plugin/client.ts b/services/gastown/container/plugin/client.ts index 6a222b7b41..3be276d24b 100644 --- a/services/gastown/container/plugin/client.ts +++ b/services/gastown/container/plugin/client.ts @@ -430,6 +430,30 @@ export class MayorGastownClient { }); } + async deleteBeads(rigId: string, beadIds: string[]): Promise<{ deleted: number }> { + return this.request<{ deleted: number }>( + this.mayorPath(`/rigs/${rigId}/beads/bulk-delete`), + { + method: 'POST', + body: JSON.stringify({ bead_ids: beadIds }), + } + ); + } + + async deleteBeadsByStatus( + rigId: string, + status: 'open' | 'in_progress' | 'in_review' | 'closed' | 'failed', + type?: string + ): Promise<{ deleted: number }> { + return this.request<{ deleted: number }>( + this.mayorPath(`/rigs/${rigId}/beads/delete-by-status`), + { + method: 'POST', + body: JSON.stringify({ status, ...(type ? { type } : {}) }), + } + ); + } + async resetAgent(rigId: string, agentId: string): Promise { await this.request(this.mayorPath(`/rigs/${rigId}/agents/${agentId}/reset`), { method: 'POST', diff --git a/services/gastown/container/plugin/mayor-tools.ts b/services/gastown/container/plugin/mayor-tools.ts index 09b28ded47..bff2459451 100644 --- a/services/gastown/container/plugin/mayor-tools.ts +++ b/services/gastown/container/plugin/mayor-tools.ts @@ -337,14 +337,22 @@ export function createMayorTools(client: MayorGastownClient) { }), gt_bead_delete: tool({ - description: 'Delete a bead. Use with caution — this is irreversible.', + description: + 'Delete one or more beads. Use with caution — this is irreversible. Pass a single UUID string or an array of UUIDs to bulk-delete up to 5000 at once.', args: { - rig_id: tool.schema.string().describe('The UUID of the rig the bead belongs to'), - bead_id: tool.schema.string().describe('The UUID of the bead to delete'), + rig_id: tool.schema.string().describe('The UUID of the rig the bead(s) belong to'), + bead_id: tool.schema + .union([tool.schema.string(), tool.schema.array(tool.schema.string())]) + .describe('A single bead UUID or an array of bead UUIDs to delete'), }, async execute(args) { - await client.deleteBead(args.rig_id, args.bead_id); - return `Bead ${args.bead_id} deleted.`; + const ids = Array.isArray(args.bead_id) ? args.bead_id : [args.bead_id]; + if (ids.length === 1 && ids[0]) { + await client.deleteBead(args.rig_id, ids[0]); + return `Bead ${ids[0]} deleted.`; + } + const result = await client.deleteBeads(args.rig_id, ids); + return `Deleted ${result.deleted} beads.`; }, }), diff --git a/services/gastown/src/prompts/mayor-system.prompt.ts b/services/gastown/src/prompts/mayor-system.prompt.ts index 78c3b6d347..b0bc7d8e1d 100644 --- a/services/gastown/src/prompts/mayor-system.prompt.ts +++ b/services/gastown/src/prompts/mayor-system.prompt.ts @@ -214,7 +214,7 @@ You can directly edit town state when things go wrong: - **gt_agent_reset** to force-reset a stuck agent to idle - **gt_convoy_close** to force-close a stuck convoy - **gt_convoy_update** to edit convoy merge_mode or feature_branch -- **gt_bead_delete** to remove beads that shouldn't exist +- **gt_bead_delete** to remove beads that shouldn't exist — accepts a single UUID or an array of UUIDs to bulk-delete up to 5000 at once - **gt_escalation_acknowledge** to acknowledge escalations Use these tools when the user reports stuck state, when you detect problems during delegation, or when you need to clean up after failures. You are the town coordinator — you have full authority over the control plane. From 606d780e00a55505959dec64b459bc348fdc6d74 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 18 Apr 2026 02:43:26 +0000 Subject: [PATCH 4/4] fix(admin): pass typeFilter to deleteBeadsByStatus to respect active type filter --- apps/web/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx b/apps/web/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx index c10084ea42..ffb7d1d446 100644 --- a/apps/web/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx +++ b/apps/web/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx @@ -137,7 +137,7 @@ export function BeadsTab({ townId }: { townId: string }) { } else if (confirmAction.type === 'bulk-delete') { bulkDeleteMutation.mutate({ townId, beadIds: confirmAction.beadIds }); } else { - deleteByStatusMutation.mutate({ townId, status: 'failed' }); + deleteByStatusMutation.mutate({ townId, status: 'failed', type: typeFilter === 'all' ? undefined : typeFilter }); } };