Skip to content

Commit 9c0cd61

Browse files
authored
feat(admin): add user-scoped Gastown admin panel (#1052)
* 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 * fix(admin): address self-review feedback on Gastown admin panel - Replace `as GastownApiResponse` casts with Zod schema parsing - Add console.error logging for non-404 failures in gastownTrpcGet - Fix XSS risk: validate pr_url scheme before rendering as href - Remove `as` casts in BeadsTab, ConfigTab, AgentInspector, BeadInspector using runtime guards and safe extraction helpers - Add onError toast handlers to all destructive mutations across AgentsTab, BeadsTab, ContainerTab, ReviewQueueTab, BeadInspector, AgentInspector - Add missing error state for health query in ContainerTab - Use Array.isArray guard for metadata.depends_on in BeadInspector * fix(admin): address bot review feedback — restore convoy progress, fix config clearing - Restore updateConvoyProgress export and explicit call in completeReviewWithResult, since closeBead short-circuits when completeReview already set status to 'closed' via direct SQL - Fix config field clearing: use null instead of undefined so the value is included in JSON serialization; update schema to accept nullable model fields - Add comment noting dependency graph reads from metadata as temporary fallback until bead_dependencies endpoint is available * feat(admin): implement admin intervention endpoints and town-wide listing Add admin-only tRPC procedures on the Gastown worker that bypass per-user ownership checks: - adminListBeads: town-wide bead listing (no rigId required) - adminListAgents: town-wide agent listing - adminForceRestartContainer: destroys the container (auto-restarts on next request) - adminForceResetAgent: unhooks bead and resets agent to idle - adminForceCloseBead: force-closes a bead - adminForceFailBead: force-fails a bead Wire the admin gastown-router.ts to proxy these via gastownTrpcMutate (new POST helper). forceRetryReview and forceRefreshCredentials remain stubs as they require more complex worker-side logic. Update UI empty states to remove stale bead-0 references. * fix: lint errors and formatting - Remove unused 'userId' prop from TownRow component - Remove unnecessary type assertion on bead.status (already typed by Zod enum) - Remove unused GastownApiResponse type alias - Format EventsTab, AuditLogDashboard, and audit page with Prettier * fix(admin): restore lightweight triage path, add admin-bypass for health/events - Add 'lightweight' flag to StartAgentRequest so triage agents dispatched as 'polecat' skip repo clone and use a git-init-only workspace (restores pre-refactor behavior) - Add adminGetAlarmStatus and adminGetTownEvents worker tRPC routes that bypass verifyTownOwnership, fixing empty health/events for towns not owned by the admin user - Pass beadId through to adminGetTownEvents so the bead inspector gets filtered events instead of only town-wide latest - Disable Force Retry button in ReviewQueueTab (stub not yet implemented on the worker) * 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. * 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
1 parent 24254cc commit 9c0cd61

29 files changed

Lines changed: 4299 additions & 41 deletions

cloudflare-gastown/container/src/agent-runner.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -439,8 +439,8 @@ export async function runAgent(originalRequest: StartAgentRequest): Promise<Mana
439439
let request = originalRequest;
440440
let workdir: string;
441441

442-
if (request.role === 'triage') {
443-
// Triage agents are pure reasoning — no code changes, no git needed.
442+
if (request.role === 'triage' || request.lightweight) {
443+
// Triage/lightweight agents are pure reasoning — no code changes, no git needed.
444444
// Use a lightweight workspace to avoid clone failures feeding the loop.
445445
workdir = await createLightweightWorkspace('triage', request.rigId);
446446
} else if (request.role === 'mayor') {

cloudflare-gastown/container/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export const StartAgentRequest = z.object({
2727
platformIntegrationId: z.string().optional(),
2828
/** Git ref to branch from (e.g. convoy feature branch). Falls back to HEAD if absent. */
2929
startPoint: z.string().optional(),
30+
/** Skip repo clone — use a lightweight git-init-only workspace (for reasoning-only agents like triage). */
31+
lightweight: z.boolean().optional(),
3032
/** Rig list for mayor agents — used to set up browse worktrees on fresh containers. */
3133
rigs: z
3234
.array(

cloudflare-gastown/src/dos/Town.do.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2640,9 +2640,7 @@ export class TownDO extends DurableObject<Env> {
26402640
userId: rigConfig.userId,
26412641
agentId: triageAgent.id,
26422642
agentName: triageAgent.name,
2643-
// Use 'triage' role so the container skips the git clone entirely.
2644-
// Triage work is purely reasoning — no code changes needed.
2645-
role: 'triage',
2643+
role: 'polecat',
26462644
identity: triageAgent.identity,
26472645
beadId: triageBead.bead_id,
26482646
beadTitle: triageBead.title,
@@ -2654,6 +2652,7 @@ export class TownDO extends DurableObject<Env> {
26542652
townConfig,
26552653
systemPromptOverride: systemPrompt,
26562654
platformIntegrationId: rigConfig.platformIntegrationId,
2655+
lightweight: true,
26572656
});
26582657

26592658
if (started) {

cloudflare-gastown/src/dos/town/beads.ts

Lines changed: 5 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -671,18 +671,11 @@ export function getConvoyDependencyEdges(
671671
}
672672

673673
/**
674-
* Find the convoy a bead belongs to (if any).
675-
*
676-
* Two cases:
677-
* 1. Normal source bead: tracked by a convoy via bead_dependencies
678-
* (bead_id = sourceBeadId, depends_on_bead_id = convoyId, type = 'tracks').
679-
* Returns the convoy bead_id.
680-
* 2. The bead IS the convoy (e.g. for the final landing MR where processConvoyLandings
681-
* passes the convoy bead_id as the source). Returns beadId itself.
674+
* Find the convoy a bead belongs to (if any) via 'tracks' dependencies.
675+
* Returns the convoy bead_id or null.
682676
*/
683677
export function getConvoyForBead(sql: SqlStorage, beadId: string): string | null {
684-
// Case 1: bead is tracked by a convoy
685-
const trackRows = [
678+
const rows = [
686679
...query(
687680
sql,
688681
/* sql */ `
@@ -694,24 +687,8 @@ export function getConvoyForBead(sql: SqlStorage, beadId: string): string | null
694687
[beadId]
695688
),
696689
];
697-
if (trackRows.length > 0) {
698-
return z.object({ depends_on_bead_id: z.string() }).parse(trackRows[0]).depends_on_bead_id;
699-
}
700-
701-
// Case 2: bead is itself a convoy (has convoy_metadata)
702-
const metaRows = [
703-
...query(
704-
sql,
705-
/* sql */ `
706-
SELECT 1 FROM ${convoy_metadata}
707-
WHERE ${convoy_metadata.bead_id} = ?
708-
`,
709-
[beadId]
710-
),
711-
];
712-
if (metaRows.length > 0) return beadId;
713-
714-
return null;
690+
if (rows.length === 0) return null;
691+
return z.object({ depends_on_bead_id: z.string() }).parse(rows[0]).depends_on_bead_id;
715692
}
716693

717694
/**

cloudflare-gastown/src/dos/town/container-dispatch.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,8 @@ export async function startAgentInContainer(
240240
platformIntegrationId?: string;
241241
/** For convoy beads: the convoy's feature branch to branch from instead of defaultBranch. */
242242
convoyFeatureBranch?: string;
243+
/** Skip repo clone — use a lightweight workspace (for reasoning-only agents like triage). */
244+
lightweight?: boolean;
243245
/** All rigs in the town (mayor only) — used to set up browse worktrees on fresh containers. */
244246
rigs?: Array<{
245247
rigId: string;
@@ -346,6 +348,7 @@ export async function startAgentInContainer(
346348
// For convoy agents, start from the convoy's feature branch so the
347349
// worktree includes all previously merged convoy work.
348350
startPoint: params.convoyFeatureBranch ? `origin/${params.convoyFeatureBranch}` : undefined,
351+
lightweight: params.lightweight,
349352
rigs: params.rigs,
350353
}),
351354
});

cloudflare-gastown/src/dos/town/review-queue.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -277,13 +277,10 @@ export function completeReviewWithResult(
277277
const mergeTimestamp = now();
278278
closeBead(sql, entry.bead_id, entry.agent_id);
279279

280-
// Explicitly trigger convoy progress for the source bead after the MR closes.
281-
// closeBead → updateBeadStatus → updateConvoyProgress, but only if the source
282-
// bead's status actually changes. If the polecat already closed the source bead
283-
// before submitting to the review queue, the guard in updateBeadStatus short-
284-
// circuits and updateConvoyProgress is never called. Calling it here directly
285-
// ensures the convoy recounts after the MR bead is now closed (not in-flight),
286-
// so the source bead passes the NOT EXISTS guard and counts toward closedCount.
280+
// closeBead → updateBeadStatus short-circuits when completeReview already
281+
// set the status to 'closed' via direct SQL, so updateConvoyProgress is
282+
// never reached transitively. Call it explicitly to ensure the convoy
283+
// recounts after the MR bead is closed.
287284
updateConvoyProgress(sql, entry.bead_id, mergeTimestamp);
288285

289286
// If this was a convoy landing MR, also set landed_at on the convoy metadata

cloudflare-gastown/src/trpc/init.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,15 @@ export const gastownProcedure = procedure.use(async ({ ctx, next }) => {
3636
}
3737
return next({ ctx });
3838
});
39+
40+
/**
41+
* Admin-only procedure — requires `isAdmin` on the JWT. Used for admin
42+
* panel endpoints that bypass per-user ownership checks (e.g. town-wide
43+
* bead/agent listing for support diagnostics).
44+
*/
45+
export const adminProcedure = procedure.use(async ({ ctx, next }) => {
46+
if (!ctx.isAdmin) {
47+
throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin access required' });
48+
}
49+
return next({ ctx });
50+
});

cloudflare-gastown/src/trpc/router.ts

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
/* eslint-disable @typescript-eslint/await-thenable -- DO RPC stubs return Rpc.Promisified which is thenable at runtime */
99
import { TRPCError } from '@trpc/server';
1010
import { z } from 'zod';
11-
import { router, gastownProcedure } from './init';
11+
import { router, gastownProcedure, adminProcedure } from './init';
1212
import { getTownDOStub } from '../dos/Town.do';
1313
import { getTownContainerStub } from '../dos/TownContainer.do';
1414
import { getGastownUserStub } from '../dos/GastownUser.do';
@@ -598,6 +598,104 @@ export const gastownRouter = router({
598598
const status = await townStub.getConvoyStatus(input.convoyId);
599599
return status ?? { ...convoy, beads: [] };
600600
}),
601+
602+
// ── Admin-only routes (bypass ownership checks) ──────────────────────
603+
604+
adminListBeads: adminProcedure
605+
.input(
606+
z.object({
607+
townId: z.string().uuid(),
608+
status: z.enum(['open', 'in_progress', 'closed', 'failed']).optional(),
609+
type: z
610+
.enum(['issue', 'message', 'escalation', 'merge_request', 'convoy', 'molecule', 'agent'])
611+
.optional(),
612+
limit: z.number().int().positive().max(500).default(200),
613+
})
614+
)
615+
.output(z.array(RpcBeadOutput))
616+
.query(async ({ ctx, input }) => {
617+
const townStub = getTownDOStub(ctx.env, input.townId);
618+
return townStub.listBeads({
619+
status: input.status,
620+
type: input.type,
621+
limit: input.limit,
622+
});
623+
}),
624+
625+
adminListAgents: adminProcedure
626+
.input(z.object({ townId: z.string().uuid() }))
627+
.output(z.array(RpcAgentOutput))
628+
.query(async ({ ctx, input }) => {
629+
const townStub = getTownDOStub(ctx.env, input.townId);
630+
return townStub.listAgents({});
631+
}),
632+
633+
adminForceRestartContainer: adminProcedure
634+
.input(z.object({ townId: z.string().uuid() }))
635+
.mutation(async ({ ctx, input }) => {
636+
const containerStub = getTownContainerStub(ctx.env, input.townId);
637+
await containerStub.destroy();
638+
}),
639+
640+
adminForceResetAgent: adminProcedure
641+
.input(z.object({ townId: z.string().uuid(), agentId: z.string().uuid() }))
642+
.mutation(async ({ ctx, input }) => {
643+
const townStub = getTownDOStub(ctx.env, input.townId);
644+
await townStub.unhookBead(input.agentId);
645+
await townStub.updateAgentStatus(input.agentId, 'idle');
646+
}),
647+
648+
adminForceCloseBead: adminProcedure
649+
.input(z.object({ townId: z.string().uuid(), beadId: z.string().uuid() }))
650+
.output(RpcBeadOutput)
651+
.mutation(async ({ ctx, input }) => {
652+
const townStub = getTownDOStub(ctx.env, input.townId);
653+
return townStub.closeBead(input.beadId, 'admin');
654+
}),
655+
656+
adminForceFailBead: adminProcedure
657+
.input(z.object({ townId: z.string().uuid(), beadId: z.string().uuid() }))
658+
.output(RpcBeadOutput)
659+
.mutation(async ({ ctx, input }) => {
660+
const townStub = getTownDOStub(ctx.env, input.townId);
661+
return townStub.updateBeadStatus(input.beadId, 'failed', 'admin');
662+
}),
663+
664+
adminGetAlarmStatus: adminProcedure
665+
.input(z.object({ townId: z.string().uuid() }))
666+
.output(RpcAlarmStatusOutput)
667+
.query(async ({ ctx, input }) => {
668+
const townStub = getTownDOStub(ctx.env, input.townId);
669+
await townStub.setTownId(input.townId);
670+
return townStub.getAlarmStatus();
671+
}),
672+
673+
adminGetTownEvents: adminProcedure
674+
.input(
675+
z.object({
676+
townId: z.string().uuid(),
677+
beadId: z.string().uuid().optional(),
678+
since: z.string().optional(),
679+
limit: z.number().int().positive().max(500).default(100),
680+
})
681+
)
682+
.output(z.array(RpcBeadEventOutput))
683+
.query(async ({ ctx, input }) => {
684+
const townStub = getTownDOStub(ctx.env, input.townId);
685+
return townStub.listBeadEvents({
686+
beadId: input.beadId,
687+
since: input.since,
688+
limit: input.limit,
689+
});
690+
}),
691+
692+
adminGetBead: adminProcedure
693+
.input(z.object({ townId: z.string().uuid(), beadId: z.string().uuid() }))
694+
.output(RpcBeadOutput.nullable())
695+
.query(async ({ ctx, input }) => {
696+
const townStub = getTownDOStub(ctx.env, input.townId);
697+
return townStub.getBeadAsync(input.beadId);
698+
}),
601699
});
602700

603701
export type GastownRouter = typeof gastownRouter;

src/app/admin/components/AppSidebar.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
Upload,
2323
Bell,
2424
Server,
25+
Network,
2526
} from 'lucide-react';
2627
import { useSession } from 'next-auth/react';
2728
import type { Session } from 'next-auth';
@@ -144,6 +145,11 @@ const productEngineeringItems: MenuItem[] = [
144145
url: '/admin/code-indexing',
145146
icon: () => <Database />,
146147
},
148+
{
149+
title: () => 'Gastown',
150+
url: '/admin/gastown',
151+
icon: () => <Network />,
152+
},
147153
];
148154

149155
const analyticsObservabilityItems: MenuItem[] = [

src/app/admin/components/UserAdmin/UserAdminDashboard.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from '@/components/ui/breadcrumb';
2222
import { UserAdminOrganizations } from '@/app/admin/components/UserAdmin/UserAdminOrganizations';
2323
import { UserAdminKiloPass } from '@/app/admin/components/UserAdmin/UserAdminKiloPass';
24+
import { UserAdminGastown } from '@/app/admin/components/UserAdmin/UserAdminGastown';
2425

2526
export function UserAdminDashboard({ ...user }: UserDetailProps) {
2627
const breadcrumbs = (
@@ -59,6 +60,8 @@ export function UserAdminDashboard({ ...user }: UserDetailProps) {
5960
<UserAdminInvoices stripe_customer_id={user.stripe_customer_id} />
6061
<UserAdminReferrals kilo_user_id={user.id} />
6162
</div>
63+
64+
<UserAdminGastown userId={user.id} />
6265
</div>
6366
</AdminPage>
6467
);

0 commit comments

Comments
 (0)