From 3003d77ec2083d8eebf7ae5d1f906a57fdca1a10 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 11 Mar 2026 21:52:40 -0500 Subject: [PATCH 1/8] feat(admin): add user-scoped Gastown admin panel for inspecting towns, beads, and agents Add a comprehensive admin panel for diagnosing and intervening in Gastown user issues. Includes town inspector with tabs for beads, agents, review queue, container, config, and events. Adds bead and agent inspector deep-dive views, an audit log for admin interventions, and tRPC endpoints exposing TownDO state to the admin dashboard. Closes #897 --- .../container/src/agent-runner.ts | 37 +- cloudflare-gastown/container/src/types.ts | 2 +- cloudflare-gastown/src/dos/Town.do.ts | 4 +- cloudflare-gastown/src/dos/town/beads.ts | 35 +- .../src/dos/town/review-queue.ts | 11 - src/app/admin/components/AppSidebar.tsx | 6 + .../UserAdmin/UserAdminDashboard.tsx | 3 + .../components/UserAdmin/UserAdminGastown.tsx | 255 +++++++ src/app/admin/gastown/page.tsx | 12 + .../gastown/towns/[townId]/AgentsTab.tsx | 177 +++++ .../admin/gastown/towns/[townId]/BeadsTab.tsx | 279 +++++++ .../gastown/towns/[townId]/ConfigTab.tsx | 387 ++++++++++ .../gastown/towns/[townId]/ContainerTab.tsx | 220 ++++++ .../gastown/towns/[townId]/EventsTab.tsx | 191 +++++ .../gastown/towns/[townId]/ReviewQueueTab.tsx | 213 ++++++ .../towns/[townId]/TownInspectorDashboard.tsx | 102 +++ .../[agentId]/AgentInspectorDashboard.tsx | 523 ++++++++++++++ .../towns/[townId]/agents/[agentId]/page.tsx | 40 + .../[townId]/audit/AuditLogDashboard.tsx | 235 ++++++ .../gastown/towns/[townId]/audit/page.tsx | 40 + .../beads/[beadId]/BeadInspectorDashboard.tsx | 634 ++++++++++++++++ .../towns/[townId]/beads/[beadId]/page.tsx | 40 + src/app/admin/gastown/towns/[townId]/page.tsx | 34 + src/components/ui/alert-dialog.tsx | 65 ++ src/routers/admin-router.ts | 2 + src/routers/admin/gastown-router.ts | 683 ++++++++++++++++++ 26 files changed, 4161 insertions(+), 69 deletions(-) create mode 100644 src/app/admin/components/UserAdmin/UserAdminGastown.tsx create mode 100644 src/app/admin/gastown/page.tsx create mode 100644 src/app/admin/gastown/towns/[townId]/AgentsTab.tsx create mode 100644 src/app/admin/gastown/towns/[townId]/BeadsTab.tsx create mode 100644 src/app/admin/gastown/towns/[townId]/ConfigTab.tsx create mode 100644 src/app/admin/gastown/towns/[townId]/ContainerTab.tsx create mode 100644 src/app/admin/gastown/towns/[townId]/EventsTab.tsx create mode 100644 src/app/admin/gastown/towns/[townId]/ReviewQueueTab.tsx create mode 100644 src/app/admin/gastown/towns/[townId]/TownInspectorDashboard.tsx create mode 100644 src/app/admin/gastown/towns/[townId]/agents/[agentId]/AgentInspectorDashboard.tsx create mode 100644 src/app/admin/gastown/towns/[townId]/agents/[agentId]/page.tsx create mode 100644 src/app/admin/gastown/towns/[townId]/audit/AuditLogDashboard.tsx create mode 100644 src/app/admin/gastown/towns/[townId]/audit/page.tsx create mode 100644 src/app/admin/gastown/towns/[townId]/beads/[beadId]/BeadInspectorDashboard.tsx create mode 100644 src/app/admin/gastown/towns/[townId]/beads/[beadId]/page.tsx create mode 100644 src/app/admin/gastown/towns/[townId]/page.tsx create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/routers/admin/gastown-router.ts diff --git a/cloudflare-gastown/container/src/agent-runner.ts b/cloudflare-gastown/container/src/agent-runner.ts index ec9c0fa8a5..71b88b5ab3 100644 --- a/cloudflare-gastown/container/src/agent-runner.ts +++ b/cloudflare-gastown/container/src/agent-runner.ts @@ -333,23 +333,24 @@ async function verifyGitCredentials( } /** - * Create a minimal git-initialized workspace for a reasoning-only agent - * (e.g. triage) that doesn't need a real repo clone. - * kilo serve requires a git repo in the working directory, so we init - * a bare local repo with an empty initial commit. + * Create a minimal git-initialized workspace for the mayor agent. + * The mayor doesn't need a real repo clone — it's a conversational + * orchestrator that delegates work via tools. But kilo serve requires + * a git repo in the working directory. */ -async function createLightweightWorkspace(label: string, rigId: string): Promise { +async function createMayorWorkspace(rigId: string): Promise { const { mkdir: mkdirAsync } = await import('node:fs/promises'); const { existsSync } = await import('node:fs'); const path = await import('node:path'); - // Validate to prevent path traversal + // Validate rigId to prevent path traversal (rigId is synthetic: "mayor-") // eslint-disable-next-line no-control-regex if (!rigId || /\.\.[/\\]|[/\\]\.\.|^\.\.$/.test(rigId) || /[\x00-\x1f]/.test(rigId)) { - throw new Error(`Invalid rigId for lightweight workspace: ${rigId}`); + throw new Error(`Invalid rigId for mayor workspace: ${rigId}`); } - const dir = path.resolve('/workspace/rigs', rigId, `${label}-workspace`); + const dir = path.resolve('/workspace/rigs', rigId, 'mayor-workspace'); await mkdirAsync(dir, { recursive: true }); + // Initialize a bare git repo if not already present if (!existsSync(`${dir}/.git`)) { const init = Bun.spawn(['git', 'init'], { cwd: dir, stdout: 'pipe', stderr: 'pipe' }); await init.exited; @@ -359,22 +360,12 @@ async function createLightweightWorkspace(label: string, rigId: string): Promise stderr: 'pipe', }); await commit.exited; - console.log(`Created ${label} workspace at ${dir}`); + console.log(`Created mayor workspace at ${dir}`); } return dir; } -/** - * Create a minimal git-initialized workspace for the mayor agent. - * The mayor doesn't need a real repo clone — it's a conversational - * orchestrator that delegates work via tools. But kilo serve requires - * a git repo in the working directory. - */ -async function createMayorWorkspace(rigId: string): Promise { - return createLightweightWorkspace('mayor', rigId); -} - /** * Write the mayor's system prompt to AGENTS.md in the workspace. * @@ -429,7 +420,7 @@ async function writeMayorSystemPromptToAgentsMd( /** * Run the full agent startup sequence: - * 1. Clone/fetch the rig's git repo (or create minimal workspace for mayor/triage) + * 1. Clone/fetch the rig's git repo (or create minimal workspace for mayor) * 2. Create an isolated worktree for the agent's branch * 3. Configure git credentials for push/fetch * 4. Start a kilo serve instance for the worktree (or reuse existing) @@ -439,11 +430,7 @@ export async function runAgent(originalRequest: StartAgentRequest): Promise; // ── Control server request/response schemas ───────────────────────────── diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index b8ba794f9a..c35e21c23b 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -2640,9 +2640,7 @@ export class TownDO extends DurableObject { userId: rigConfig.userId, agentId: triageAgent.id, agentName: triageAgent.name, - // Use 'triage' role so the container skips the git clone entirely. - // Triage work is purely reasoning — no code changes needed. - role: 'triage', + role: 'polecat', identity: triageAgent.identity, beadId: triageBead.bead_id, beadTitle: triageBead.title, diff --git a/cloudflare-gastown/src/dos/town/beads.ts b/cloudflare-gastown/src/dos/town/beads.ts index 03e0abd974..e0f16ca563 100644 --- a/cloudflare-gastown/src/dos/town/beads.ts +++ b/cloudflare-gastown/src/dos/town/beads.ts @@ -230,7 +230,7 @@ export function updateBeadStatus( * recount closed beads and update convoy_metadata. Auto-lands the * convoy when all tracked beads are closed. */ -export function updateConvoyProgress(sql: SqlStorage, beadId: string, timestamp: string): void { +function updateConvoyProgress(sql: SqlStorage, beadId: string, timestamp: string): void { const convoyRows = [ ...query( sql, @@ -671,18 +671,11 @@ export function getConvoyDependencyEdges( } /** - * Find the convoy a bead belongs to (if any). - * - * Two cases: - * 1. Normal source bead: tracked by a convoy via bead_dependencies - * (bead_id = sourceBeadId, depends_on_bead_id = convoyId, type = 'tracks'). - * Returns the convoy bead_id. - * 2. The bead IS the convoy (e.g. for the final landing MR where processConvoyLandings - * passes the convoy bead_id as the source). Returns beadId itself. + * Find the convoy a bead belongs to (if any) via 'tracks' dependencies. + * Returns the convoy bead_id or null. */ export function getConvoyForBead(sql: SqlStorage, beadId: string): string | null { - // Case 1: bead is tracked by a convoy - const trackRows = [ + const rows = [ ...query( sql, /* sql */ ` @@ -694,24 +687,8 @@ export function getConvoyForBead(sql: SqlStorage, beadId: string): string | null [beadId] ), ]; - if (trackRows.length > 0) { - return z.object({ depends_on_bead_id: z.string() }).parse(trackRows[0]).depends_on_bead_id; - } - - // Case 2: bead is itself a convoy (has convoy_metadata) - const metaRows = [ - ...query( - sql, - /* sql */ ` - SELECT 1 FROM ${convoy_metadata} - WHERE ${convoy_metadata.bead_id} = ? - `, - [beadId] - ), - ]; - if (metaRows.length > 0) return beadId; - - return null; + if (rows.length === 0) return null; + return z.object({ depends_on_bead_id: z.string() }).parse(rows[0]).depends_on_bead_id; } /** diff --git a/cloudflare-gastown/src/dos/town/review-queue.ts b/cloudflare-gastown/src/dos/town/review-queue.ts index f595d78c48..462916d683 100644 --- a/cloudflare-gastown/src/dos/town/review-queue.ts +++ b/cloudflare-gastown/src/dos/town/review-queue.ts @@ -18,7 +18,6 @@ import { getBead, closeBead, updateBeadStatus, - updateConvoyProgress, createBead, getConvoyForBead, getConvoyFeatureBranch, @@ -274,18 +273,8 @@ export function completeReviewWithResult( }); if (input.status === 'merged') { - const mergeTimestamp = now(); closeBead(sql, entry.bead_id, entry.agent_id); - // Explicitly trigger convoy progress for the source bead after the MR closes. - // closeBead → updateBeadStatus → updateConvoyProgress, but only if the source - // bead's status actually changes. If the polecat already closed the source bead - // before submitting to the review queue, the guard in updateBeadStatus short- - // circuits and updateConvoyProgress is never called. Calling it here directly - // ensures the convoy recounts after the MR bead is now closed (not in-flight), - // so the source bead passes the NOT EXISTS guard and counts toward closedCount. - updateConvoyProgress(sql, entry.bead_id, mergeTimestamp); - // If this was a convoy landing MR, also set landed_at on the convoy metadata const sourceBead = getBead(sql, entry.bead_id); if (sourceBead?.type === 'convoy') { diff --git a/src/app/admin/components/AppSidebar.tsx b/src/app/admin/components/AppSidebar.tsx index 151aa1ea19..9618d3a51d 100644 --- a/src/app/admin/components/AppSidebar.tsx +++ b/src/app/admin/components/AppSidebar.tsx @@ -22,6 +22,7 @@ import { Upload, Bell, Server, + Network, } from 'lucide-react'; import { useSession } from 'next-auth/react'; import type { Session } from 'next-auth'; @@ -144,6 +145,11 @@ const productEngineeringItems: MenuItem[] = [ url: '/admin/code-indexing', icon: () => , }, + { + title: () => 'Gastown', + url: '/admin/gastown', + icon: () => , + }, ]; const analyticsObservabilityItems: MenuItem[] = [ diff --git a/src/app/admin/components/UserAdmin/UserAdminDashboard.tsx b/src/app/admin/components/UserAdmin/UserAdminDashboard.tsx index ca472237d9..ba6ec04062 100644 --- a/src/app/admin/components/UserAdmin/UserAdminDashboard.tsx +++ b/src/app/admin/components/UserAdmin/UserAdminDashboard.tsx @@ -21,6 +21,7 @@ import { } from '@/components/ui/breadcrumb'; import { UserAdminOrganizations } from '@/app/admin/components/UserAdmin/UserAdminOrganizations'; import { UserAdminKiloPass } from '@/app/admin/components/UserAdmin/UserAdminKiloPass'; +import { UserAdminGastown } from '@/app/admin/components/UserAdmin/UserAdminGastown'; export function UserAdminDashboard({ ...user }: UserDetailProps) { const breadcrumbs = ( @@ -59,6 +60,8 @@ export function UserAdminDashboard({ ...user }: UserDetailProps) { + + ); diff --git a/src/app/admin/components/UserAdmin/UserAdminGastown.tsx b/src/app/admin/components/UserAdmin/UserAdminGastown.tsx new file mode 100644 index 0000000000..850eb11c3b --- /dev/null +++ b/src/app/admin/components/UserAdmin/UserAdminGastown.tsx @@ -0,0 +1,255 @@ +'use client'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { formatDate } from '@/lib/admin-utils'; +import { toast } from 'sonner'; +import { ExternalLink, RotateCcw } from 'lucide-react'; + +type HealthDotProps = { status: 'green' | 'yellow' | 'red' | 'unknown' }; + +function HealthDot({ status }: HealthDotProps) { + const colors = { + green: 'bg-green-500', + yellow: 'bg-yellow-400', + red: 'bg-red-500', + unknown: 'bg-gray-400', + }; + const labels = { + green: 'Healthy', + yellow: 'Degraded', + red: 'Critical', + unknown: 'Unknown', + }; + return ( + + + {labels[status]} + + ); +} + +function deriveHealthStatus( + health: { + agents: { working: number; stalled: number; dead: number }; + beads: { failed: number }; + patrol: { guppEscalations: number; stalledAgents: number }; + } | null +): 'green' | 'yellow' | 'red' | 'unknown' { + if (!health) return 'unknown'; + const { agents, beads, patrol } = health; + if (agents.dead > 0 || patrol.guppEscalations > 0) return 'red'; + if (agents.stalled > 0 || beads.failed > 0 || patrol.stalledAgents > 0) return 'yellow'; + return 'green'; +} + +function TownRow({ town, userId }: { town: { id: string; name: string; created_at: string }; userId: string }) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const healthQuery = useQuery({ + ...trpc.admin.gastown.getTownHealth.queryOptions({ townId: town.id }), + retry: false, + }); + + const restartMutation = useMutation( + trpc.admin.gastown.forceRestartContainer.mutationOptions({ + onSuccess: () => { + toast.success(`Container restart requested for town ${town.name}`); + void queryClient.invalidateQueries( + trpc.admin.gastown.getTownHealth.queryOptions({ townId: town.id }) + ); + }, + onError: err => { + toast.error(`Failed to restart container: ${err.message}`); + }, + }) + ); + + const health = healthQuery.data ?? null; + const healthStatus = deriveHealthStatus(health); + + return ( + + +
{town.name}
+
{town.id}
+
+ + {healthQuery.isLoading ? ( + Loading… + ) : ( + + )} + + + {health ? ( + + {health.agents.working} working / {health.agents.idle} idle + + ) : ( + + )} + + + {health ? ( + + {health.beads.open + health.beads.inProgress} open + + ) : ( + + )} + + + {formatDate(town.created_at)} + + +
+ + +
+
+
+ ); +} + +export function UserAdminGastown({ userId }: { userId: string }) { + const trpc = useTRPC(); + + const townsQuery = useQuery({ + ...trpc.admin.gastown.getUserTowns.queryOptions({ userId }), + retry: false, + }); + + const rigsQuery = useQuery({ + ...trpc.admin.gastown.getUserRigs.queryOptions({ userId }), + retry: false, + }); + + return ( + + + Gastown + Towns and rigs owned by this user + + + {/* Towns */} +
+

+ Towns +

+ {townsQuery.isLoading && ( +

Loading towns…

+ )} + {townsQuery.error && ( +

Failed to load towns

+ )} + {townsQuery.data && townsQuery.data.length === 0 && ( +

No towns found

+ )} + {townsQuery.data && townsQuery.data.length > 0 && ( + + + + Town + Health + Agents + Beads + Created + Actions + + + + {townsQuery.data.map(town => ( + + ))} + +
+ )} +
+ + {/* Rigs */} +
+

+ Rigs +

+ {rigsQuery.isLoading && ( +

Loading rigs…

+ )} + {rigsQuery.error && ( +

Failed to load rigs

+ )} + {rigsQuery.data && rigsQuery.data.length === 0 && ( +

No rigs found

+ )} + {rigsQuery.data && rigsQuery.data.length > 0 && ( + + + + Rig Name + Git URL + Integration + Created + + + + {rigsQuery.data.map(rig => ( + + +
{rig.name}
+
{rig.id}
+
+ + + {rig.git_url} + + + + {rig.platform_integration_id ? ( + + Linked + + ) : ( + + Not linked + + )} + + + {formatDate(rig.created_at)} + +
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/src/app/admin/gastown/page.tsx b/src/app/admin/gastown/page.tsx new file mode 100644 index 0000000000..20f6163c6d --- /dev/null +++ b/src/app/admin/gastown/page.tsx @@ -0,0 +1,12 @@ +import { redirect } from 'next/navigation'; +import { getUserFromAuth } from '@/lib/user.server'; + +export default async function GastownIndexPage() { + const { authFailedResponse } = await getUserFromAuth({ adminOnly: true }); + if (authFailedResponse) { + redirect('/admin/unauthorized'); + } + + // Gastown data is scoped to a user — navigate to a user first. + redirect('/admin/users'); +} diff --git a/src/app/admin/gastown/towns/[townId]/AgentsTab.tsx b/src/app/admin/gastown/towns/[townId]/AgentsTab.tsx new file mode 100644 index 0000000000..531f2f59d8 --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/AgentsTab.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Badge } from '@/components/ui/badge'; +import Link from 'next/link'; +import { formatDistanceToNow } from 'date-fns'; + +const AGENT_STATUS_COLORS: Record = { + working: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20', + idle: 'bg-blue-500/10 text-blue-400 border-blue-500/20', + dead: 'bg-red-500/10 text-red-400 border-red-500/20', + stalled: 'bg-orange-500/10 text-orange-400 border-orange-500/20', +}; + +export function AgentsTab({ townId }: { townId: string }) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const [agentToReset, setAgentToReset] = useState<{ id: string; name: string } | null>(null); + + const agentsQuery = useQuery(trpc.admin.gastown.listAgents.queryOptions({ townId })); + + const forceResetMutation = useMutation( + trpc.admin.gastown.forceResetAgent.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries(trpc.admin.gastown.listAgents.queryFilter({ townId })); + setAgentToReset(null); + }, + }) + ); + + const agents = agentsQuery.data ?? []; + + return ( + + + Agents + + + {agentsQuery.isLoading && ( +

Loading agents…

+ )} + {agentsQuery.isError && ( +

+ Failed to load agents: {agentsQuery.error.message} +

+ )} + {!agentsQuery.isLoading && agents.length === 0 && ( +

+ No agents found. (Town-wide listing requires bead 0 admin endpoints.) +

+ )} + {agents.length > 0 && ( +
+ + + + + + + + + + + + + + {agents.map(agent => ( + + + + + + + + + + ))} + +
AgentRoleStatusHooked BeadDispatchesLast ActiveActions
+ +
{agent.name}
+
+ {agent.id.slice(0, 8)}… +
+ +
+ {agent.role} + + + {agent.status} + + + {agent.current_hook_bead_id ? ( + + {agent.current_hook_bead_id.slice(0, 8)}… + + ) : ( + + )} + + {agent.dispatch_attempts} + + {agent.last_activity_at + ? formatDistanceToNow(new Date(agent.last_activity_at), { + addSuffix: true, + }) + : '—'} + + +
+
+ )} +
+ + setAgentToReset(null)}> + + + Force Reset Agent + + This will reset agent{' '} + {agentToReset?.name} to idle status and unhook + any hooked bead. This action is logged in the audit trail. + + + + + + + + +
+ ); +} diff --git a/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx b/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx new file mode 100644 index 0000000000..bb4b575e20 --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx @@ -0,0 +1,279 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Badge } from '@/components/ui/badge'; +import Link from 'next/link'; +import { formatDistanceToNow } from 'date-fns'; + +type BeadStatus = 'open' | 'in_progress' | 'closed' | 'failed'; +type BeadType = + | 'issue' + | 'message' + | 'escalation' + | 'merge_request' + | 'convoy' + | 'molecule' + | 'agent'; + +const STATUS_COLORS: Record = { + open: 'bg-blue-500/10 text-blue-400 border-blue-500/20', + in_progress: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20', + closed: 'bg-green-500/10 text-green-400 border-green-500/20', + failed: 'bg-red-500/10 text-red-400 border-red-500/20', +}; + +type ConfirmAction = { + type: 'close' | 'fail'; + beadId: string; + title: string; +}; + +export function BeadsTab({ townId }: { townId: string }) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const [statusFilter, setStatusFilter] = useState('all'); + const [typeFilter, setTypeFilter] = useState('all'); + const [confirmAction, setConfirmAction] = useState(null); + + const beadsQuery = useQuery( + trpc.admin.gastown.listBeads.queryOptions({ + townId, + status: statusFilter === 'all' ? undefined : statusFilter, + type: typeFilter === 'all' ? undefined : typeFilter, + }) + ); + + const forceCloseMutation = useMutation( + trpc.admin.gastown.forceCloseBead.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries(trpc.admin.gastown.listBeads.queryFilter({ townId })); + setConfirmAction(null); + }, + }) + ); + + const forceFailMutation = useMutation( + trpc.admin.gastown.forceFailBead.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries(trpc.admin.gastown.listBeads.queryFilter({ townId })); + setConfirmAction(null); + }, + }) + ); + + const handleConfirm = () => { + if (!confirmAction) return; + if (confirmAction.type === 'close') { + forceCloseMutation.mutate({ townId, beadId: confirmAction.beadId }); + } else { + forceFailMutation.mutate({ townId, beadId: confirmAction.beadId }); + } + }; + + const beads = beadsQuery.data ?? []; + const isPending = forceCloseMutation.isPending || forceFailMutation.isPending; + + return ( + + +
+ Beads +
+ + + +
+
+
+ + {beadsQuery.isLoading && ( +

Loading beads…

+ )} + {beadsQuery.isError && ( +

+ Failed to load beads: {beadsQuery.error.message} +

+ )} + {!beadsQuery.isLoading && beads.length === 0 && ( +

+ No beads found. (Town-wide listing requires bead 0 admin endpoints.) +

+ )} + {beads.length > 0 && ( +
+ + + + + + + + + + + + + {beads.map(bead => ( + + + + + + + + + ))} + +
BeadTypeStatusAgentCreatedActions
+ + {bead.bead_id.slice(0, 8)}… + {bead.title && ( + {bead.title} + )} + + + {bead.type} + + + {bead.status} + + + {bead.assignee_agent_bead_id ? ( + + {bead.assignee_agent_bead_id.slice(0, 8)}… + + ) : ( + + )} + + {formatDistanceToNow(new Date(bead.created_at), { addSuffix: true })} + +
+ + +
+
+
+ )} +
+ + 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. + + + + + + + + +
+ ); +} diff --git a/src/app/admin/gastown/towns/[townId]/ConfigTab.tsx b/src/app/admin/gastown/towns/[townId]/ConfigTab.tsx new file mode 100644 index 0000000000..a1e161eb0b --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/ConfigTab.tsx @@ -0,0 +1,387 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { formatDistanceToNow } from 'date-fns'; + +export function ConfigTab({ townId }: { townId: string }) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const configQuery = useQuery(trpc.admin.gastown.getTownConfig.queryOptions({ townId })); + const credEventsQuery = useQuery( + trpc.admin.gastown.listCredentialEvents.queryOptions({ townId }) + ); + + const updateConfigMutation = useMutation( + trpc.admin.gastown.updateTownConfig.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries( + trpc.admin.gastown.getTownConfig.queryFilter({ townId }) + ); + setEditingField(null); + }, + }) + ); + + const [editingField, setEditingField] = useState(null); + const [editValue, setEditValue] = useState(''); + + const config = configQuery.data; + const credEvents = credEventsQuery.data ?? []; + + const startEdit = (field: string, currentValue: string) => { + setEditingField(field); + setEditValue(currentValue); + }; + + const saveField = (field: string) => { + if (field === 'default_model') { + updateConfigMutation.mutate({ townId, update: { default_model: editValue || undefined } }); + } else if (field === 'small_model') { + updateConfigMutation.mutate({ townId, update: { small_model: editValue || undefined } }); + } else if (field === 'max_polecats_per_rig') { + const num = parseInt(editValue, 10); + if (!isNaN(num) && num > 0) { + updateConfigMutation.mutate({ townId, update: { max_polecats_per_rig: num } }); + } + } else if (field === 'merge_strategy') { + updateConfigMutation.mutate({ + townId, + update: { merge_strategy: editValue as 'direct' | 'pr' }, + }); + } + }; + + return ( +
+ {/* Town Config */} + + + Town Config + + + {configQuery.isLoading && ( +

Loading config…

+ )} + {configQuery.isError && ( +

+ Failed to load config: {configQuery.error.message} +

+ )} + {config && ( +
+ {/* Merge Strategy */} +
+
+ +

How branches are merged

+
+ {editingField === 'merge_strategy' ? ( +
+ + + +
+ ) : ( +
+ {config.merge_strategy} + +
+ )} +
+ + {/* Default Model */} +
+
+ +

Primary AI model for agents

+
+ {editingField === 'default_model' ? ( +
+ setEditValue(e.target.value)} + className="w-64" + placeholder="e.g. anthropic/claude-sonnet-4" + /> + + +
+ ) : ( +
+ + {config.default_model ?? '—'} + + +
+ )} +
+ + {/* Small Model */} +
+
+ +

Lightweight model for simple tasks

+
+ {editingField === 'small_model' ? ( +
+ setEditValue(e.target.value)} + className="w-64" + placeholder="e.g. anthropic/claude-haiku" + /> + + +
+ ) : ( +
+ + {config.small_model ?? '—'} + + +
+ )} +
+ + {/* Max Polecats */} +
+
+ +

+ Concurrency limit per rig +

+
+ {editingField === 'max_polecats_per_rig' ? ( +
+ setEditValue(e.target.value)} + className="w-24" + min={1} + /> + + +
+ ) : ( +
+ + {config.max_polecats_per_rig ?? '—'} + + +
+ )} +
+ + {/* Refinery Config */} + {config.refinery && ( +
+ +
+
+ Auto Merge: + {String(config.refinery.auto_merge)} +
+
+ Require Clean Merge: + {String(config.refinery.require_clean_merge)} +
+
+ Gates: + {config.refinery.gates.join(', ') || '—'} +
+
+
+ )} + + {/* Git Auth */} +
+ +
+ {config.git_auth.platform_integration_id && ( +
+ Platform Integration: + {config.git_auth.platform_integration_id.slice(0, 16)}… +
+ )} + {config.git_auth.github_token && ( +
+ GitHub Token: + •••••••• +
+ )} + {config.git_auth.gitlab_token && ( +
+ GitLab Token: + •••••••• +
+ )} + {config.git_auth.gitlab_instance_url && ( +
+ GitLab Instance URL: + {config.git_auth.gitlab_instance_url} +
+ )} + {!config.git_auth.github_token && + !config.git_auth.gitlab_token && + !config.git_auth.platform_integration_id && ( + No git auth configured + )} +
+
+ + {updateConfigMutation.isError && ( +

+ Failed to update config: {updateConfigMutation.error.message} +

+ )} +
+ )} +
+
+ + {/* Credential Events */} + + + Credential Events + + + {credEventsQuery.isLoading && ( +

Loading credential events…

+ )} + {!credEventsQuery.isLoading && credEvents.length === 0 && ( +

+ No credential events. (Requires bead 0 admin endpoints.) +

+ )} + {credEvents.length > 0 && ( +
+ {credEvents.map(event => ( +
+
+ {event.event_type} + {event.rig_id && ( + + rig: {event.rig_id.slice(0, 8)}… + + )} +
+ + {formatDistanceToNow(new Date(event.created_at), { addSuffix: true })} + +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/src/app/admin/gastown/towns/[townId]/ContainerTab.tsx b/src/app/admin/gastown/towns/[townId]/ContainerTab.tsx new file mode 100644 index 0000000000..774a395190 --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/ContainerTab.tsx @@ -0,0 +1,220 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Badge } from '@/components/ui/badge'; +import { formatDistanceToNow } from 'date-fns'; + +export function ContainerTab({ townId }: { townId: string }) { + const trpc = useTRPC(); + + const [showRestartDialog, setShowRestartDialog] = useState(false); + + const healthQuery = useQuery(trpc.admin.gastown.getTownHealth.queryOptions({ townId })); + const eventsQuery = useQuery(trpc.admin.gastown.listContainerEvents.queryOptions({ townId })); + const configQuery = useQuery(trpc.admin.gastown.getTownConfig.queryOptions({ townId })); + + const forceRestartMutation = useMutation( + trpc.admin.gastown.forceRestartContainer.mutationOptions({ + onSuccess: () => { + setShowRestartDialog(false); + }, + }) + ); + + const health = healthQuery.data; + const containerEvents = eventsQuery.data ?? []; + const envVars = configQuery.data?.env_vars ?? {}; + + // Derive container health status from the alarm status snapshot + let containerStatus: 'running' | 'stopped' | 'unknown' = 'unknown'; + if (health) { + const hasDeadAgents = health.agents.dead > 0; + const hasWorkingAgents = health.agents.working > 0; + if (hasWorkingAgents) { + containerStatus = 'running'; + } else if (hasDeadAgents) { + containerStatus = 'stopped'; + } else { + containerStatus = 'running'; // idle but alive + } + } + + const healthBadgeClass = + containerStatus === 'running' + ? 'bg-green-500/10 text-green-400 border-green-500/20' + : containerStatus === 'stopped' + ? 'bg-red-500/10 text-red-400 border-red-500/20' + : 'bg-gray-500/10 text-gray-400 border-gray-500/20'; + + return ( +
+ {/* Health & Actions */} + + +
+ Container Health + +
+
+ + {healthQuery.isLoading && ( +

Loading health status…

+ )} + {!healthQuery.isLoading && ( +
+
+ Status: + + {containerStatus} + +
+ {health && ( +
+
+
Working Agents
+
{health.agents.working}
+
+
+
Idle Agents
+
{health.agents.idle}
+
+
+
Dead Agents
+
+ {health.agents.dead} +
+
+
+
Open Beads
+
{health.beads.open}
+
+
+ )} + {health && ( +
+
Alarm interval
+
{health.alarm.intervalLabel}
+ {health.alarm.nextFireAt && ( +
+ Next:{' '} + {formatDistanceToNow(new Date(health.alarm.nextFireAt), { addSuffix: true })} +
+ )} +
+ )} +
+ )} +
+
+ + {/* Recent Events */} + + + Container Events + + + {eventsQuery.isLoading && ( +

Loading events…

+ )} + {!eventsQuery.isLoading && containerEvents.length === 0 && ( +

+ No container events. (Requires bead 0 admin endpoints.) +

+ )} + {containerEvents.length > 0 && ( +
+ {containerEvents.map(event => ( +
+
+ {event.event_type} + {event.data && Object.keys(event.data).length > 0 && ( +
+                        {JSON.stringify(event.data, null, 2)}
+                      
+ )} +
+ + {formatDistanceToNow(new Date(event.created_at), { addSuffix: true })} + +
+ ))} +
+ )} +
+
+ + {/* Environment Variables */} + + + Environment Variables + + + {configQuery.isLoading && ( +

Loading config…

+ )} + {!configQuery.isLoading && Object.keys(envVars).length === 0 && ( +

No environment variables configured.

+ )} + {Object.keys(envVars).length > 0 && ( +
+ {Object.keys(envVars) + .sort() + .map(key => ( +
+ {key} + •••••••• +
+ ))} +
+ )} +
+
+ + + + + Force Restart Container + + This will force-restart the Gastown container for this town. All running agents will + be interrupted. This action is logged in the audit trail. + + + + + + + + +
+ ); +} diff --git a/src/app/admin/gastown/towns/[townId]/EventsTab.tsx b/src/app/admin/gastown/towns/[townId]/EventsTab.tsx new file mode 100644 index 0000000000..dc3f0451fa --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/EventsTab.tsx @@ -0,0 +1,191 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; +import { formatDistanceToNow, format } from 'date-fns'; +import { ChevronDown, ChevronUp } from 'lucide-react'; + +type SortDir = 'asc' | 'desc'; + +export function EventsTab({ townId }: { townId: string }) { + const trpc = useTRPC(); + + const [sortDir, setSortDir] = useState('desc'); + const [beadFilter, setBeadFilter] = useState(''); + const [agentFilter, setAgentFilter] = useState(''); + const [limit, setLimit] = useState(100); + + const eventsQuery = useQuery( + trpc.admin.gastown.getBeadEvents.queryOptions({ townId, limit }) + ); + + const events = eventsQuery.data ?? []; + + const filtered = events.filter(e => { + if (beadFilter && !e.bead_id.toLowerCase().includes(beadFilter.toLowerCase())) return false; + if (agentFilter && e.agent_id && !e.agent_id.toLowerCase().includes(agentFilter.toLowerCase())) + return false; + return true; + }); + + const sorted = [...filtered].sort((a, b) => { + const diff = new Date(a.created_at).getTime() - new Date(b.created_at).getTime(); + return sortDir === 'desc' ? -diff : diff; + }); + + const toggleSort = () => setSortDir(d => (d === 'desc' ? 'asc' : 'desc')); + + return ( + + +
+ Events Timeline +
+ setBeadFilter(e.target.value)} + className="h-8 w-48 text-xs" + /> + setAgentFilter(e.target.value)} + className="h-8 w-48 text-xs" + /> +
+
+
+ + {eventsQuery.isLoading && ( +

Loading events…

+ )} + {eventsQuery.isError && ( +

+ Failed to load events: {eventsQuery.error.message} +

+ )} + {!eventsQuery.isLoading && sorted.length === 0 && ( +

+ {events.length === 0 + ? 'No events found for this town.' + : 'No events match the current filters.'} +

+ )} + {sorted.length > 0 && ( +
+ + + + + + + + + + + + {sorted.map(event => ( + + + + + + + + ))} + +
+ + EventBeadAgentChange
+ + {formatDistanceToNow(new Date(event.created_at), { addSuffix: true })} + + + + {event.event_type} + + + + {event.bead_id.slice(0, 8)}… + + {event.rig_name && ( + + ({event.rig_name}) + + )} + + {event.agent_id ? ( + + {event.agent_id.slice(0, 8)}… + + ) : ( + + )} + + {(event.old_value ?? event.new_value) ? ( +
+ {event.old_value && ( + + {event.old_value.length > 30 + ? `${event.old_value.slice(0, 30)}…` + : event.old_value} + + )} + {event.old_value && event.new_value && ( + + )} + {event.new_value && ( + + {event.new_value.length > 30 + ? `${event.new_value.slice(0, 30)}…` + : event.new_value} + + )} +
+ ) : ( + + )} +
+
+ )} + {events.length >= limit && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/app/admin/gastown/towns/[townId]/ReviewQueueTab.tsx b/src/app/admin/gastown/towns/[townId]/ReviewQueueTab.tsx new file mode 100644 index 0000000000..f6a6d0d562 --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/ReviewQueueTab.tsx @@ -0,0 +1,213 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Badge } from '@/components/ui/badge'; +import Link from 'next/link'; +import { formatDistanceToNow } from 'date-fns'; +import { ExternalLink } from 'lucide-react'; + +const STATUS_COLORS: Record = { + open: 'bg-blue-500/10 text-blue-400 border-blue-500/20', + in_progress: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20', + closed: 'bg-green-500/10 text-green-400 border-green-500/20', + failed: 'bg-red-500/10 text-red-400 border-red-500/20', +}; + +type EntryToRetry = { id: string; title: string }; + +export function ReviewQueueTab({ townId }: { townId: string }) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const [entryToRetry, setEntryToRetry] = useState(null); + + // Fetch in-progress merge request beads as the review queue + const inProgressQuery = useQuery( + trpc.admin.gastown.listBeads.queryOptions({ + townId, + type: 'merge_request', + status: 'in_progress', + }) + ); + + const openQuery = useQuery( + trpc.admin.gastown.listBeads.queryOptions({ + townId, + type: 'merge_request', + status: 'open', + }) + ); + + const forceRetryMutation = useMutation( + trpc.admin.gastown.forceRetryReview.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries(trpc.admin.gastown.listBeads.queryFilter({ townId })); + setEntryToRetry(null); + }, + }) + ); + + const queueEntries = [...(inProgressQuery.data ?? []), ...(openQuery.data ?? [])]; + const isLoading = inProgressQuery.isLoading || openQuery.isLoading; + const isError = inProgressQuery.isError || openQuery.isError; + + return ( + + + Review Queue + + + {isLoading && ( +

+ Loading review queue… +

+ )} + {isError && ( +

Failed to load review queue.

+ )} + {!isLoading && queueEntries.length === 0 && ( +

+ No active review queue entries. (Town-wide listing requires bead 0 admin endpoints.) +

+ )} + {queueEntries.length > 0 && ( +
+ + + + + + + + + + + + + {queueEntries.map(entry => { + const prUrl = + typeof entry.metadata['pr_url'] === 'string' ? entry.metadata['pr_url'] : null; + return ( + + + + + + + + + ); + })} + +
BeadPR URL + Assigned Agent + + Time in Queue + StatusActions
+ +
{entry.title}
+
+ {entry.bead_id.slice(0, 8)}… +
+ +
+ {prUrl ? ( + + {prUrl} + + + ) : ( + + )} + + {entry.assignee_agent_bead_id ? ( + + {entry.assignee_agent_bead_id.slice(0, 8)}… + + ) : ( + + )} + + {formatDistanceToNow(new Date(entry.created_at), { addSuffix: false })} + + + {entry.status} + + + +
+
+ )} +
+ + setEntryToRetry(null)}> + + + Force Retry Review + + This will force-retry the review for{' '} + {entryToRetry?.title}. This action is logged + in the audit trail. + + + + + + + + +
+ ); +} diff --git a/src/app/admin/gastown/towns/[townId]/TownInspectorDashboard.tsx b/src/app/admin/gastown/towns/[townId]/TownInspectorDashboard.tsx new file mode 100644 index 0000000000..f802fe7b24 --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/TownInspectorDashboard.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { useSearchParams, useRouter, usePathname } from 'next/navigation'; +import { useCallback } from 'react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Button } from '@/components/ui/button'; +import { BeadsTab } from './BeadsTab'; +import { AgentsTab } from './AgentsTab'; +import { ReviewQueueTab } from './ReviewQueueTab'; +import { ContainerTab } from './ContainerTab'; +import { ConfigTab } from './ConfigTab'; +import { EventsTab } from './EventsTab'; +import Link from 'next/link'; + +const VALID_TABS = ['beads', 'agents', 'review', 'container', 'config', 'events'] as const; +type Tab = (typeof VALID_TABS)[number]; + +function isValidTab(value: string | null): value is Tab { + return value !== null && (VALID_TABS as readonly string[]).includes(value); +} + +const tabTriggerClass = + 'text-muted-foreground hover:text-foreground data-[state=active]:border-foreground data-[state=active]:text-foreground rounded-none border-b-2 border-transparent px-0 py-3 text-sm font-medium transition-colors data-[state=active]:border-0 data-[state=active]:border-b-2 data-[state=active]:bg-transparent data-[state=active]:shadow-none'; + +export function TownInspectorDashboard({ townId }: { townId: string }) { + const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + + const tabParam = searchParams.get('tab'); + const activeTab: Tab = isValidTab(tabParam) ? tabParam : 'beads'; + + const onTabChange = useCallback( + (value: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (value === 'beads') { + params.delete('tab'); + } else { + params.set('tab', value); + } + const qs = params.toString(); + router.replace(`${pathname}${qs ? `?${qs}` : ''}`, { scroll: false }); + }, + [searchParams, router, pathname] + ); + + return ( +
+
+
+

Town Inspector

+

{townId}

+
+ +
+ + + + + Beads + + + Agents + + + Review Queue + + + Container + + + Config + + + Events + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} diff --git a/src/app/admin/gastown/towns/[townId]/agents/[agentId]/AgentInspectorDashboard.tsx b/src/app/admin/gastown/towns/[townId]/agents/[agentId]/AgentInspectorDashboard.tsx new file mode 100644 index 0000000000..31d368505d --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/agents/[agentId]/AgentInspectorDashboard.tsx @@ -0,0 +1,523 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import Link from 'next/link'; +import { formatDistanceToNow, format } from 'date-fns'; +import { ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react'; + +const AGENT_STATUS_COLORS: Record = { + working: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20', + idle: 'bg-blue-500/10 text-blue-400 border-blue-500/20', + dead: 'bg-red-500/10 text-red-400 border-red-500/20', + stalled: 'bg-orange-500/10 text-orange-400 border-orange-500/20', +}; + + +type SortDir = 'asc' | 'desc'; + +export function AgentInspectorDashboard({ + townId, + agentId, +}: { + townId: string; + agentId: string; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const [confirmReset, setConfirmReset] = useState(false); + const [eventSearch, setEventSearch] = useState(''); + const [eventSortDir, setEventSortDir] = useState('desc'); + const [eventLimit, setEventLimit] = useState(500); + + // Fetch agent from list + const agentsQuery = useQuery(trpc.admin.gastown.listAgents.queryOptions({ townId })); + const agent = (agentsQuery.data ?? []).find(a => a.id === agentId) ?? null; + + const agentEventsQuery = useQuery( + trpc.admin.gastown.getAgentEvents.queryOptions({ townId, agentId, limit: eventLimit }) + ); + + const beadEventsQuery = useQuery( + trpc.admin.gastown.getBeadEvents.queryOptions({ townId, limit: 500 }) + ); + + const dispatchAttemptsQuery = useQuery( + trpc.admin.gastown.listDispatchAttempts.queryOptions({ townId, agentId }) + ); + + const forceResetMutation = useMutation( + trpc.admin.gastown.forceResetAgent.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries(trpc.admin.gastown.listAgents.queryFilter({ townId })); + setConfirmReset(false); + }, + }) + ); + + const agentEvents = agentEventsQuery.data ?? []; + const allBeadEvents = beadEventsQuery.data ?? []; + const dispatchAttempts = dispatchAttemptsQuery.data ?? []; + + // Events related to this agent from bead events + const agentBeadEvents = allBeadEvents.filter(e => e.agent_id === agentId); + + // Deduplicated bead IDs that this agent was ever hooked to + const hookedBeadIds = Array.from(new Set(agentBeadEvents.map(e => e.bead_id))); + + // Filtered + sorted agent events + const filteredAgentEvents = agentEvents + .filter(e => { + if (!eventSearch) return true; + const ev = e as Record; + return JSON.stringify(ev).toLowerCase().includes(eventSearch.toLowerCase()); + }) + .sort((a, b) => { + const ea = a as Record; + const eb = b as Record; + const ta = new Date(ea['created_at'] as string).getTime(); + const tb = new Date(eb['created_at'] as string).getTime(); + return eventSortDir === 'desc' ? tb - ta : ta - tb; + }); + + return ( +
+ {/* Header */} +
+
+ + + Back to Town Inspector + +

Agent Inspector

+

{agentId}

+
+ {agent && ( + + {agent.status} + + )} +
+ + {agentsQuery.isLoading && ( +

Loading agent…

+ )} + + {agent && ( + <> + {/* Agent overview */} + + + Overview + + +
+
+
+ Name +
+
{agent.name}
+
+
+
+ Role +
+
{agent.role}
+
+
+
+ Identity +
+
{agent.identity}
+
+
+
+ Dispatch Attempts +
+
{agent.dispatch_attempts}
+
+
+
+ Last Active +
+
+ {agent.last_activity_at + ? formatDistanceToNow(new Date(agent.last_activity_at), { + addSuffix: true, + }) + : '—'} +
+
+
+
+ Created +
+
+ {formatDistanceToNow(new Date(agent.created_at), { addSuffix: true })} +
+
+ {agent.current_hook_bead_id && ( +
+
+ Current Hooked Bead +
+
+ + {agent.current_hook_bead_id.slice(0, 8)}… + +
+
+ )} + {agent.rig_id && ( +
+
+ Rig +
+
{agent.rig_id.slice(0, 8)}…
+
+ )} +
+
+
+ + {/* Admin Actions */} + + + Admin Actions + + + + + + + )} + + {/* Current/Past Hooked Beads */} + + + Hooked Beads + + + {beadEventsQuery.isLoading && ( +

Loading…

+ )} + {!beadEventsQuery.isLoading && hookedBeadIds.length === 0 && ( +

+ No bead history found for this agent. +

+ )} + {hookedBeadIds.length > 0 && ( +
+ + + + + + + + + + {hookedBeadIds.map(bid => { + const beadEvs = agentBeadEvents.filter(e => e.bead_id === bid); + const latest = beadEvs.reduce((a, b) => + new Date(a.created_at) > new Date(b.created_at) ? a : b + ); + return ( + + + + + + ); + })} + +
BeadEvents + Last Event +
+ + {bid.slice(0, 8)}… + + + {beadEvs.length} + + {formatDistanceToNow(new Date(latest.created_at), { + addSuffix: true, + })} +
+
+ )} +
+
+ + {/* Status Timeline */} + + +
+ Status Timeline +
+ setEventSearch(e.target.value)} + className="h-8 w-48 text-xs" + /> + +
+
+
+ + {agentEventsQuery.isLoading && ( +

Loading events…

+ )} + {!agentEventsQuery.isLoading && filteredAgentEvents.length === 0 && ( +

+ {agentEvents.length === 0 + ? 'No agent events found. (Requires bead 0 admin endpoints.)' + : 'No events match the current search.'} +

+ )} + {filteredAgentEvents.length > 0 && ( +
+ + + + + + + + + + {filteredAgentEvents.map((rawEvent, idx) => { + const ev = rawEvent as Record; + const eventId = String(ev['id'] ?? ev['agent_event_id'] ?? idx); + const createdAt = String(ev['created_at'] ?? ''); + const eventType = String(ev['event_type'] ?? ''); + const oldValue = ev['old_value'] as string | null | undefined; + const newValue = ev['new_value'] as string | null | undefined; + return ( + + + + + + ); + })} + +
TimeEventChange
+ {createdAt + ? formatDistanceToNow(new Date(createdAt), { addSuffix: true }) + : '—'} + + + {eventType} + + + {(oldValue ?? newValue) ? ( +
+ {oldValue && ( + + {String(oldValue).length > 40 + ? `${String(oldValue).slice(0, 40)}…` + : String(oldValue)} + + )} + {oldValue && newValue && ( + + )} + {newValue && ( + + {String(newValue).length > 40 + ? `${String(newValue).slice(0, 40)}…` + : String(newValue)} + + )} +
+ ) : ( + + )} +
+
+ )} + {agentEvents.length >= eventLimit && ( +
+ +
+ )} +
+
+ + {/* Dispatch Attempts */} + + + Dispatch Attempt History + + + {dispatchAttemptsQuery.isLoading && ( +

+ Loading dispatch attempts… +

+ )} + {!dispatchAttemptsQuery.isLoading && dispatchAttempts.length === 0 && ( +

+ No dispatch attempts found. +

+ )} + {dispatchAttempts.length > 0 && ( +
+ + + + + + + + + + + {dispatchAttempts.map(attempt => ( + + + + + + + ))} + +
TimeResultBeadError
+ {formatDistanceToNow(new Date(attempt.attempted_at), { + addSuffix: true, + })} + + + {attempt.success ? 'Success' : 'Failed'} + + + {attempt.bead_id ? ( + + {attempt.bead_id.slice(0, 8)}… + + ) : ( + + )} + + {attempt.error_message ? ( + + {attempt.error_message.length > 80 + ? `${attempt.error_message.slice(0, 80)}…` + : attempt.error_message} + + ) : ( + + )} +
+
+ )} +
+
+ + {/* Confirm Reset AlertDialog */} + + + + Force Reset Agent + + This will reset agent{' '} + {agent?.name ?? agentId.slice(0, 8)}{' '} + to idle status and unhook any hooked bead. This action is logged in the audit trail. + + + + Cancel + forceResetMutation.mutate({ townId, agentId })} + disabled={forceResetMutation.isPending} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {forceResetMutation.isPending ? 'Resetting…' : 'Force Reset'} + + + + + + +
+ ); +} diff --git a/src/app/admin/gastown/towns/[townId]/agents/[agentId]/page.tsx b/src/app/admin/gastown/towns/[townId]/agents/[agentId]/page.tsx new file mode 100644 index 0000000000..90471a150f --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/agents/[agentId]/page.tsx @@ -0,0 +1,40 @@ +import AdminPage from '@/app/admin/components/AdminPage'; +import { + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; +import { AgentInspectorDashboard } from './AgentInspectorDashboard'; + +export default async function AgentInspectorPage({ + params, +}: { + params: Promise<{ townId: string; agentId: string }>; +}) { + const { townId, agentId } = await params; + + const breadcrumbs = ( + <> + + Gastown + + + + + Town {townId.slice(0, 8)}… + + + + + Agent {agentId.slice(0, 8)}… + + + ); + + return ( + + + + ); +} diff --git a/src/app/admin/gastown/towns/[townId]/audit/AuditLogDashboard.tsx b/src/app/admin/gastown/towns/[townId]/audit/AuditLogDashboard.tsx new file mode 100644 index 0000000000..fd4734ce67 --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/audit/AuditLogDashboard.tsx @@ -0,0 +1,235 @@ +'use client'; + +import React, { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import Link from 'next/link'; +import { formatDistanceToNow, format } from 'date-fns'; +import { ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react'; + +type SortDir = 'asc' | 'desc'; + +export function AuditLogDashboard({ townId }: { townId: string }) { + const trpc = useTRPC(); + + const [search, setSearch] = useState(''); + const [sortDir, setSortDir] = useState('desc'); + const [expandedIds, setExpandedIds] = useState>(new Set()); + + const auditQuery = useQuery(trpc.admin.gastown.listAuditLog.queryOptions({ townId })); + const entries = auditQuery.data ?? []; + + const filtered = entries.filter(entry => { + if (!search) return true; + const q = search.toLowerCase(); + return ( + entry.admin_user_id.toLowerCase().includes(q) || + entry.action.toLowerCase().includes(q) || + (entry.target_type ?? '').toLowerCase().includes(q) || + (entry.target_id ?? '').toLowerCase().includes(q) + ); + }); + + const sorted = [...filtered].sort((a, b) => { + const diff = + new Date(a.performed_at).getTime() - new Date(b.performed_at).getTime(); + return sortDir === 'desc' ? -diff : diff; + }); + + const toggleExpand = (id: string) => { + setExpandedIds(prev => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + return ( +
+ {/* Header */} +
+ + + Back to Town Inspector + +

Audit Log

+

{townId}

+
+ + + +
+ Admin Actions + setSearch(e.target.value)} + className="h-8 w-64 text-xs" + /> +
+
+ + {auditQuery.isLoading && ( +

+ Loading audit log… +

+ )} + {auditQuery.isError && ( +

+ Failed to load audit log: {auditQuery.error.message} +

+ )} + {!auditQuery.isLoading && sorted.length === 0 && ( +

+ {entries.length === 0 + ? 'No audit log entries found. (Requires bead 0 admin endpoints.)' + : 'No entries match the current search.'} +

+ )} + {sorted.length > 0 && ( +
+ + + + + + + + + + + + + {sorted.map(entry => { + const isExpanded = expandedIds.has(entry.id); + const hasDetail = + entry.detail != null && Object.keys(entry.detail).length > 0; + return ( + + + + + + + + + + {isExpanded && hasDetail && ( + + + + )} + + ); + })} + +
+ + AdminAction + Target Type + + Target ID + Detail
+ {formatDistanceToNow(new Date(entry.performed_at), { + addSuffix: true, + })} + + {entry.admin_user_id} + + + {entry.action} + + + {entry.target_type ?? '—'} + + {entry.target_id ? ( + + ) : ( + + )} + + {hasDetail ? ( + + ) : ( + + )} +
+
+                                {JSON.stringify(entry.detail, null, 2)}
+                              
+
+
+ )} +
+
+
+ ); +} + +function TargetLink({ + townId, + targetType, + targetId, +}: { + townId: string; + targetType: string | null; + targetId: string; +}) { + if (targetType === 'bead') { + return ( + + {targetId.slice(0, 8)}… + + ); + } + if (targetType === 'agent') { + return ( + + {targetId.slice(0, 8)}… + + ); + } + return {targetId.slice(0, 8)}…; +} diff --git a/src/app/admin/gastown/towns/[townId]/audit/page.tsx b/src/app/admin/gastown/towns/[townId]/audit/page.tsx new file mode 100644 index 0000000000..b4e1d249cc --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/audit/page.tsx @@ -0,0 +1,40 @@ +import AdminPage from '@/app/admin/components/AdminPage'; +import { + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; +import { AuditLogDashboard } from './AuditLogDashboard'; + +export default async function AuditLogPage({ + params, +}: { + params: Promise<{ townId: string }>; +}) { + const { townId } = await params; + + const breadcrumbs = ( + <> + + Gastown + + + + + Town {townId.slice(0, 8)}… + + + + + Audit Log + + + ); + + return ( + + + + ); +} diff --git a/src/app/admin/gastown/towns/[townId]/beads/[beadId]/BeadInspectorDashboard.tsx b/src/app/admin/gastown/towns/[townId]/beads/[beadId]/BeadInspectorDashboard.tsx new file mode 100644 index 0000000000..123bf6f9ce --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/beads/[beadId]/BeadInspectorDashboard.tsx @@ -0,0 +1,634 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import Link from 'next/link'; +import { formatDistanceToNow, format } from 'date-fns'; +import { ArrowLeft } from 'lucide-react'; + +const STATUS_COLORS: Record = { + open: 'bg-blue-500/10 text-blue-400 border-blue-500/20', + in_progress: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20', + closed: 'bg-green-500/10 text-green-400 border-green-500/20', + failed: 'bg-red-500/10 text-red-400 border-red-500/20', +}; + +type ConfirmActionType = 'close' | 'fail' | 'reset_agent'; + +type ConfirmAction = { + type: ConfirmActionType; + label: string; + description: string; +}; + +export function BeadInspectorDashboard({ + townId, + beadId, +}: { + townId: string; + beadId: string; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [confirmAction, setConfirmAction] = useState(null); + + // Fetch all beads — used both for finding this bead and computing dependency graph. + // listBeads returns [] until bead-0 admin endpoints are merged. + const allBeadsQuery = useQuery( + trpc.admin.gastown.listBeads.queryOptions({ townId }) + ); + const allBeads = allBeadsQuery.data ?? []; + const bead = allBeads.find(b => b.bead_id === beadId) ?? null; + + const eventsQuery = useQuery( + trpc.admin.gastown.getBeadEvents.queryOptions({ townId, beadId, limit: 500 }) + ); + + const dispatchAttemptsQuery = useQuery( + trpc.admin.gastown.listDispatchAttempts.queryOptions({ townId, beadId }) + ); + + const invalidateAll = () => { + void queryClient.invalidateQueries(trpc.admin.gastown.listBeads.queryFilter({ townId })); + void queryClient.invalidateQueries(trpc.admin.gastown.getBeadEvents.queryFilter({ townId })); + }; + + const forceCloseMutation = useMutation( + trpc.admin.gastown.forceCloseBead.mutationOptions({ + onSuccess: () => { + invalidateAll(); + setConfirmAction(null); + }, + }) + ); + + const forceFailMutation = useMutation( + trpc.admin.gastown.forceFailBead.mutationOptions({ + onSuccess: () => { + invalidateAll(); + setConfirmAction(null); + }, + }) + ); + + const forceResetAgentMutation = useMutation( + trpc.admin.gastown.forceResetAgent.mutationOptions({ + onSuccess: () => { + invalidateAll(); + setConfirmAction(null); + }, + }) + ); + + const handleConfirm = () => { + if (!confirmAction) return; + if (confirmAction.type === 'close') { + forceCloseMutation.mutate({ townId, beadId }); + } else if (confirmAction.type === 'fail') { + forceFailMutation.mutate({ townId, beadId }); + } else if (confirmAction.type === 'reset_agent' && bead?.assignee_agent_bead_id) { + forceResetAgentMutation.mutate({ townId, agentId: bead.assignee_agent_bead_id }); + } + }; + + const isMutating = + forceCloseMutation.isPending || forceFailMutation.isPending || forceResetAgentMutation.isPending; + + // Filter events to only those for this bead (server doesn't filter by beadId yet) + const events = (eventsQuery.data ?? []).filter(e => e.bead_id === beadId); + const dispatchAttempts = dispatchAttemptsQuery.data ?? []; + + // Dependency graph: beads whose metadata references this beadId + // Beads that this bead depends on: look at bead.metadata.depends_on + const thisBeadDeps = bead != null + ? ((bead.metadata as Record)['depends_on'] as string[] | undefined) ?? [] + : []; + const dependsOnBeads = allBeads.filter(b => thisBeadDeps.includes(b.bead_id)); + + // Beads that depend on this bead: look for other beads whose metadata.depends_on includes beadId + const dependentBeads = allBeads.filter(b => { + const meta = b.metadata as Record; + const deps = meta['depends_on'] as string[] | undefined; + return Array.isArray(deps) && deps.includes(beadId); + }); + + // Agent history from events (deduplicated) + const agentHistory = Array.from( + new Map( + events + .filter(e => e.agent_id != null) + .map(e => [e.agent_id, e]) + ).values() + ); + + // Convoy membership + const convoyId = + bead != null + ? ((bead.metadata as Record)['convoy_id'] as string | undefined) + : undefined; + const convoyBead = convoyId ? allBeads.find(b => b.bead_id === convoyId) : undefined; + + return ( +
+ {/* Header */} +
+
+ + + Back to Town Inspector + +

Bead Inspector

+

{beadId}

+
+ {bead && ( + + {bead.status} + + )} +
+ + {allBeadsQuery.isLoading && ( +

Loading bead…

+ )} + {allBeadsQuery.isError && ( +

+ Failed to load bead: {allBeadsQuery.error.message} +

+ )} + + {bead && ( + <> + {/* Bead summary */} + + + Overview + + +
+
+
+ Type +
+
{bead.type}
+
+
+
+ Priority +
+
{bead.priority}
+
+
+
+ Created +
+
+ {formatDistanceToNow(new Date(bead.created_at), { addSuffix: true })} +
+
+ {bead.closed_at && ( +
+
+ Closed +
+
+ {formatDistanceToNow(new Date(bead.closed_at), { addSuffix: true })} +
+
+ )} + {bead.assignee_agent_bead_id && ( +
+
+ Assigned Agent +
+
+ + {bead.assignee_agent_bead_id.slice(0, 8)}… + +
+
+ )} + {bead.rig_id && ( +
+
+ Rig +
+
{bead.rig_id.slice(0, 8)}…
+
+ )} + {bead.labels.length > 0 && ( +
+
+ Labels +
+
+ {bead.labels.map(label => ( + + {label} + + ))} +
+
+ )} + {bead.title && ( +
+
+ Title +
+
{bead.title}
+
+ )} +
+
+
+ + {/* Admin Actions */} + + + Admin Actions + + +
+ + + +
+
+
+ + )} + + {/* State History Timeline */} + + + State History + + + {eventsQuery.isLoading && ( +

Loading events…

+ )} + {eventsQuery.isError && ( +

+ Failed to load events: {eventsQuery.error.message} +

+ )} + {!eventsQuery.isLoading && events.length === 0 && ( +

No events found.

+ )} + {events.length > 0 && ( +
+ {[...events] + .sort( + (a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ) + .map((event, idx) => ( +
+ {/* Timeline line + dot */} +
+
+ {idx < events.length - 1 && ( +
+ )} +
+
+
+ + {event.event_type} + + {event.agent_id && ( + + agent {event.agent_id.slice(0, 8)}… + + )} + + {formatDistanceToNow(new Date(event.created_at), { addSuffix: true })} + +
+ {(event.old_value ?? event.new_value) && ( +
+ {event.old_value && ( + + {event.old_value} + + )} + {event.old_value && event.new_value && ( + + )} + {event.new_value && ( + + {event.new_value} + + )} +
+ )} +
+
+ ))} +
+ )} + + + + {/* Assigned Agent History */} + + + Assigned Agent History + + + {agentHistory.length === 0 ? ( +

+ No agent assignments found in event history. +

+ ) : ( +
+ + + + + + + + + + {agentHistory.map(event => ( + + + + + + ))} + +
AgentEventTime
+ + {event.agent_id?.slice(0, 8)}… + + + + {event.event_type} + + + {formatDistanceToNow(new Date(event.created_at), { addSuffix: true })} +
+
+ )} +
+
+ + {/* Dependency Graph */} + + + Dependencies + + + {convoyBead && ( +
+

+ Convoy +

+ + {convoyBead.bead_id.slice(0, 8)}… + {convoyBead.title && {convoyBead.title}} + +
+ )} + +
+

+ Depends On +

+ {dependsOnBeads.length === 0 ? ( +

None

+ ) : ( +
    + {dependsOnBeads.map(b => ( +
  • + + {b.bead_id.slice(0, 8)}… + {b.title && {b.title}} + + + {b.status} + +
  • + ))} +
+ )} +
+ +
+

+ Depended On By +

+ {dependentBeads.length === 0 ? ( +

None

+ ) : ( +
    + {dependentBeads.map(b => ( +
  • + + {b.bead_id.slice(0, 8)}… + {b.title && {b.title}} + + + {b.status} + +
  • + ))} +
+ )} +
+
+
+ + {/* Dispatch Attempts */} + + + Dispatch Attempts + + + {dispatchAttemptsQuery.isLoading && ( +

+ Loading dispatch attempts… +

+ )} + {!dispatchAttemptsQuery.isLoading && dispatchAttempts.length === 0 && ( +

+ No dispatch attempts found. +

+ )} + {dispatchAttempts.length > 0 && ( +
+ + + + + + + + + + + {dispatchAttempts.map(attempt => ( + + + + + + + ))} + +
TimeResultAgentError
+ {formatDistanceToNow(new Date(attempt.attempted_at), { + addSuffix: true, + })} + + + {attempt.success ? 'Success' : 'Failed'} + + + {attempt.agent_id ? ( + + {attempt.agent_id.slice(0, 8)}… + + ) : ( + + )} + + {attempt.error_message ?? ( + + )} +
+
+ )} +
+
+ + {/* Confirm AlertDialog */} + setConfirmAction(null)}> + + + {confirmAction?.label} + {confirmAction?.description} + + + Cancel + + {isMutating ? 'Processing…' : confirmAction?.label} + + + + +
+ ); +} diff --git a/src/app/admin/gastown/towns/[townId]/beads/[beadId]/page.tsx b/src/app/admin/gastown/towns/[townId]/beads/[beadId]/page.tsx new file mode 100644 index 0000000000..e061320614 --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/beads/[beadId]/page.tsx @@ -0,0 +1,40 @@ +import AdminPage from '@/app/admin/components/AdminPage'; +import { + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; +import { BeadInspectorDashboard } from './BeadInspectorDashboard'; + +export default async function BeadInspectorPage({ + params, +}: { + params: Promise<{ townId: string; beadId: string }>; +}) { + const { townId, beadId } = await params; + + const breadcrumbs = ( + <> + + Gastown + + + + + Town {townId.slice(0, 8)}… + + + + + Bead {beadId.slice(0, 8)}… + + + ); + + return ( + + + + ); +} diff --git a/src/app/admin/gastown/towns/[townId]/page.tsx b/src/app/admin/gastown/towns/[townId]/page.tsx new file mode 100644 index 0000000000..6533c171a1 --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/page.tsx @@ -0,0 +1,34 @@ +import AdminPage from '@/app/admin/components/AdminPage'; +import { + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; +import { TownInspectorDashboard } from './TownInspectorDashboard'; + +export default async function TownInspectorPage({ + params, +}: { + params: Promise<{ townId: string }>; +}) { + const { townId } = await params; + + const breadcrumbs = ( + <> + + Gastown + + + + Town {townId.slice(0, 8)}… + + + ); + + return ( + + + + ); +} diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000000..15b9c638c8 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,65 @@ +'use client'; + +// AlertDialog built on top of the existing Dialog primitive. +// Mirrors the shadcn/ui AlertDialog API surface so callers can use the standard +// AlertDialog, AlertDialogContent, AlertDialogHeader, etc. naming. + +import * as React from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +const AlertDialog = Dialog; + +// AlertDialogContent hides the default close button so users must use Cancel/Action +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogContent.displayName = 'AlertDialogContent'; + +const AlertDialogHeader = DialogHeader; +const AlertDialogFooter = DialogFooter; +const AlertDialogTitle = DialogTitle; +const AlertDialogDescription = DialogDescription; + +type AlertDialogCancelProps = React.ComponentPropsWithoutRef; + +const AlertDialogCancel = React.forwardRef( + ({ className, ...props }, ref) => ( + @@ -184,8 +185,8 @@ export function ReviewQueueTab({ townId }: { townId: string }) { Force Retry Review This will force-retry the review for{' '} - {entryToRetry?.title}. This action is logged - in the audit trail. + {entryToRetry?.title}. This action is logged in + the audit trail. @@ -198,8 +199,7 @@ export function ReviewQueueTab({ townId }: { townId: string }) { diff --git a/src/routers/admin/gastown-router.ts b/src/routers/admin/gastown-router.ts index a0dbfa4fe1..d8f9b66363 100644 --- a/src/routers/admin/gastown-router.ts +++ b/src/routers/admin/gastown-router.ts @@ -392,9 +392,7 @@ export const adminGastownRouter = createTRPCRouter({ /** * Get the alarm status snapshot for a town. - * Calls the tRPC gastown.getAlarmStatus with admin JWT. - * Requires admin-bypass support on the Gastown worker (bead 0) since - * verifyTownOwnership is checked per town. + * Calls the admin-bypass gastown.adminGetAlarmStatus endpoint. */ getTownHealth: adminProcedure .input(z.object({ townId: z.string().uuid() })) @@ -402,7 +400,7 @@ export const adminGastownRouter = createTRPCRouter({ .query(async ({ input, ctx }) => { return gastownTrpcGet( ctx.user, - 'gastown.getAlarmStatus', + 'gastown.adminGetAlarmStatus', { townId: input.townId }, AlarmStatusRecord ); @@ -453,8 +451,8 @@ export const adminGastownRouter = createTRPCRouter({ .query(async ({ input, ctx }) => { const result = await gastownTrpcGet( ctx.user, - 'gastown.getTownEvents', - { townId: input.townId, since: input.since, limit: input.limit }, + 'gastown.adminGetTownEvents', + { townId: input.townId, beadId: input.beadId, since: input.since, limit: input.limit }, z.array(BeadEventRecord) ); return result ?? []; From 4b7ea4424bbc156740e0a113b488ac4431547f7c Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Thu, 12 Mar 2026 13:42:10 -0500 Subject: [PATCH 7/8] fix: revert unrelated agent-runner.ts refactor, keep only lightweight flag Restore agent-runner.ts and container types.ts to match main, removing the unrelated createLightweightWorkspace inlining and triage role deletion from the fork's feature branch. The only additions vs main are: - lightweight field on StartAgentRequest (types.ts) - request.lightweight check in runAgent (agent-runner.ts) This keeps the triage role's existing lightweight workspace path intact while also supporting the new lightweight flag for polecat agents dispatched with lightweight: true. --- .../container/src/agent-runner.ts | 40 ++++++++++++------- cloudflare-gastown/container/src/types.ts | 2 +- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/cloudflare-gastown/container/src/agent-runner.ts b/cloudflare-gastown/container/src/agent-runner.ts index 102288a63e..8cf6ac785e 100644 --- a/cloudflare-gastown/container/src/agent-runner.ts +++ b/cloudflare-gastown/container/src/agent-runner.ts @@ -333,24 +333,23 @@ async function verifyGitCredentials( } /** - * Create a minimal git-initialized workspace for the mayor agent. - * The mayor doesn't need a real repo clone — it's a conversational - * orchestrator that delegates work via tools. But kilo serve requires - * a git repo in the working directory. + * Create a minimal git-initialized workspace for a reasoning-only agent + * (e.g. triage) that doesn't need a real repo clone. + * kilo serve requires a git repo in the working directory, so we init + * a bare local repo with an empty initial commit. */ -async function createMayorWorkspace(rigId: string): Promise { +async function createLightweightWorkspace(label: string, rigId: string): Promise { const { mkdir: mkdirAsync } = await import('node:fs/promises'); const { existsSync } = await import('node:fs'); const path = await import('node:path'); - // Validate rigId to prevent path traversal (rigId is synthetic: "mayor-") + // Validate to prevent path traversal // eslint-disable-next-line no-control-regex if (!rigId || /\.\.[/\\]|[/\\]\.\.|^\.\.$/.test(rigId) || /[\x00-\x1f]/.test(rigId)) { - throw new Error(`Invalid rigId for mayor workspace: ${rigId}`); + throw new Error(`Invalid rigId for lightweight workspace: ${rigId}`); } - const dir = path.resolve('/workspace/rigs', rigId, 'mayor-workspace'); + const dir = path.resolve('/workspace/rigs', rigId, `${label}-workspace`); await mkdirAsync(dir, { recursive: true }); - // Initialize a bare git repo if not already present if (!existsSync(`${dir}/.git`)) { const init = Bun.spawn(['git', 'init'], { cwd: dir, stdout: 'pipe', stderr: 'pipe' }); await init.exited; @@ -360,12 +359,22 @@ async function createMayorWorkspace(rigId: string): Promise { stderr: 'pipe', }); await commit.exited; - console.log(`Created mayor workspace at ${dir}`); + console.log(`Created ${label} workspace at ${dir}`); } return dir; } +/** + * Create a minimal git-initialized workspace for the mayor agent. + * The mayor doesn't need a real repo clone — it's a conversational + * orchestrator that delegates work via tools. But kilo serve requires + * a git repo in the working directory. + */ +async function createMayorWorkspace(rigId: string): Promise { + return createLightweightWorkspace('mayor', rigId); +} + /** * Write the mayor's system prompt to AGENTS.md in the workspace. * @@ -420,7 +429,7 @@ async function writeMayorSystemPromptToAgentsMd( /** * Run the full agent startup sequence: - * 1. Clone/fetch the rig's git repo (or create minimal workspace for mayor) + * 1. Clone/fetch the rig's git repo (or create minimal workspace for mayor/triage) * 2. Create an isolated worktree for the agent's branch * 3. Configure git credentials for push/fetch * 4. Start a kilo serve instance for the worktree (or reuse existing) @@ -430,9 +439,12 @@ export async function runAgent(originalRequest: StartAgentRequest): Promise; // ── Control server request/response schemas ───────────────────────────── From 3638aa9e30d87599726ea6d3713844107f01e621 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Thu, 12 Mar 2026 14:20:14 -0500 Subject: [PATCH 8/8] fix(admin): AlertDialogCancel dismisses dialog, fetch bead by ID - Wrap AlertDialogCancel with DialogClose so clicking Cancel actually closes the confirmation modal - Add adminGetBead worker tRPC route and getBead admin procedure for fetching a single bead by ID (not limited by pagination) - BeadInspectorDashboard now uses getBead for primary data, listBeads only for the dependency graph - Update stale comment about server-side beadId filtering --- cloudflare-gastown/src/trpc/router.ts | 8 ++++++++ .../beads/[beadId]/BeadInspectorDashboard.tsx | 16 ++++++++++------ src/components/ui/alert-dialog.tsx | 5 ++++- src/routers/admin/gastown-router.ts | 13 ++++++++++++- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/cloudflare-gastown/src/trpc/router.ts b/cloudflare-gastown/src/trpc/router.ts index 5863cdb0fa..c804c702b9 100644 --- a/cloudflare-gastown/src/trpc/router.ts +++ b/cloudflare-gastown/src/trpc/router.ts @@ -688,6 +688,14 @@ export const gastownRouter = router({ limit: input.limit, }); }), + + adminGetBead: adminProcedure + .input(z.object({ townId: z.string().uuid(), beadId: z.string().uuid() })) + .output(RpcBeadOutput.nullable()) + .query(async ({ ctx, input }) => { + const townStub = getTownDOStub(ctx.env, input.townId); + return townStub.getBeadAsync(input.beadId); + }), }); export type GastownRouter = typeof gastownRouter; diff --git a/src/app/admin/gastown/towns/[townId]/beads/[beadId]/BeadInspectorDashboard.tsx b/src/app/admin/gastown/towns/[townId]/beads/[beadId]/BeadInspectorDashboard.tsx index 25d8865ce8..36a0a5f83d 100644 --- a/src/app/admin/gastown/towns/[townId]/beads/[beadId]/BeadInspectorDashboard.tsx +++ b/src/app/admin/gastown/towns/[townId]/beads/[beadId]/BeadInspectorDashboard.tsx @@ -41,10 +41,13 @@ export function BeadInspectorDashboard({ townId, beadId }: { townId: string; bea const queryClient = useQueryClient(); const [confirmAction, setConfirmAction] = useState(null); - // Fetch all beads — used both for finding this bead and computing dependency graph. + // Fetch the specific bead by ID (not limited by pagination). + const beadQuery = useQuery(trpc.admin.gastown.getBead.queryOptions({ townId, beadId })); + const bead = beadQuery.data ?? null; + + // Fetch all beads for computing the dependency graph. const allBeadsQuery = useQuery(trpc.admin.gastown.listBeads.queryOptions({ townId })); const allBeads = allBeadsQuery.data ?? []; - const bead = allBeads.find(b => b.bead_id === beadId) ?? null; const eventsQuery = useQuery( trpc.admin.gastown.getBeadEvents.queryOptions({ townId, beadId, limit: 500 }) @@ -55,6 +58,7 @@ export function BeadInspectorDashboard({ townId, beadId }: { townId: string; bea ); const invalidateAll = () => { + void queryClient.invalidateQueries(trpc.admin.gastown.getBead.queryFilter({ townId, beadId })); void queryClient.invalidateQueries(trpc.admin.gastown.listBeads.queryFilter({ townId })); void queryClient.invalidateQueries(trpc.admin.gastown.getBeadEvents.queryFilter({ townId })); }; @@ -114,7 +118,7 @@ export function BeadInspectorDashboard({ townId, beadId }: { townId: string; bea forceFailMutation.isPending || forceResetAgentMutation.isPending; - // Filter events to only those for this bead (server doesn't filter by beadId yet) + // Filter events to only those for this bead const events = (eventsQuery.data ?? []).filter(e => e.bead_id === beadId); const dispatchAttempts = dispatchAttemptsQuery.data ?? []; @@ -165,12 +169,12 @@ export function BeadInspectorDashboard({ townId, beadId }: { townId: string; bea )}
- {allBeadsQuery.isLoading && ( + {beadQuery.isLoading && (

Loading bead…

)} - {allBeadsQuery.isError && ( + {beadQuery.isError && (

- Failed to load bead: {allBeadsQuery.error.message} + Failed to load bead: {beadQuery.error.message}

)} diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx index 15b9c638c8..bc243d1c1d 100644 --- a/src/components/ui/alert-dialog.tsx +++ b/src/components/ui/alert-dialog.tsx @@ -7,6 +7,7 @@ import * as React from 'react'; import { Dialog, + DialogClose, DialogContent, DialogHeader, DialogFooter, @@ -41,7 +42,9 @@ type AlertDialogCancelProps = React.ComponentPropsWithoutRef; const AlertDialogCancel = React.forwardRef( ({ className, ...props }, ref) => ( -