Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion apps/web/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import Link from 'next/link';
import { formatDistanceToNow } from 'date-fns';
import { Trash2 } from 'lucide-react';

const beadStatuses = ['open', 'in_progress', 'closed', 'failed'] as const;
const beadStatuses = ['open', 'in_progress', 'in_review', 'closed', 'failed'] as const;
type BeadStatus = (typeof beadStatuses)[number];

const beadTypes = [
Expand All @@ -44,6 +44,7 @@ type BeadType = (typeof beadTypes)[number];
const STATUS_COLORS: Record<BeadStatus, string> = {
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',
in_review: 'bg-purple-500/10 text-purple-400 border-purple-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',
};
Expand Down Expand Up @@ -239,6 +240,7 @@ export function BeadsTab({ townId }: { townId: string }) {
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="in_review">In Review</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
<SelectItem value="failed">Failed</SelectItem>
</SelectContent>
Expand Down Expand Up @@ -321,6 +323,9 @@ export function BeadsTab({ townId }: { townId: string }) {
<th className="text-muted-foreground pb-2 text-left font-medium">Bead</th>
<th className="text-muted-foreground pb-2 text-left font-medium">Type</th>
<th className="text-muted-foreground pb-2 text-left font-medium">Status</th>
<th className="text-muted-foreground pb-2 text-left font-medium">
Failure Reason
</th>
<th className="text-muted-foreground pb-2 text-left font-medium">Agent</th>
<th className="text-muted-foreground pb-2 text-left font-medium">Created</th>
<th className="text-muted-foreground pb-2 text-right font-medium">Actions</th>
Expand Down Expand Up @@ -353,6 +358,22 @@ export function BeadsTab({ townId }: { townId: string }) {
{bead.status}
</Badge>
</td>
<td className="py-2 pr-4">
{bead.status === 'failed' && bead.failure_reason ? (
<div className="max-w-72" title={bead.failure_reason.details}>
<p className="truncate text-sm text-red-300">
{bead.failure_reason.message}
</p>
<p className="text-muted-foreground font-mono text-xs">
{bead.failure_reason.code} · {bead.failure_reason.source}
</p>
</div>
) : bead.status === 'failed' ? (
<span className="text-muted-foreground text-xs">No reason recorded</span>
) : (
<span className="text-muted-foreground text-xs">—</span>
)}
</td>
<td className="py-2 pr-4">
{bead.assignee_agent_bead_id ? (
<Link
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { ArrowLeft } from 'lucide-react';
const STATUS_COLORS: Record<string, string> = {
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',
in_review: 'bg-purple-500/10 text-purple-400 border-purple-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',
};
Expand Down Expand Up @@ -262,6 +263,28 @@ export function BeadInspectorDashboard({ townId, beadId }: { townId: string; bea
<dd>{bead.title}</dd>
</div>
)}
{bead.status === 'failed' && (
<div className="col-span-2 md:col-span-3">
<dt className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
Failure Reason
</dt>
{bead.failure_reason ? (
<dd className="mt-1 rounded-md border border-red-500/20 bg-red-500/5 p-3">
<p className="text-sm text-red-200">{bead.failure_reason.message}</p>
<p className="text-muted-foreground mt-1 font-mono text-xs">
{bead.failure_reason.code} · {bead.failure_reason.source}
</p>
{bead.failure_reason.details && (
<pre className="text-muted-foreground mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded bg-black/20 p-2 font-mono text-xs">
{bead.failure_reason.details}
</pre>
)}
</dd>
) : (
<dd className="text-muted-foreground">No failure reason recorded.</dd>
)}
</div>
)}
</dl>
</CardContent>
</Card>
Expand Down
14 changes: 12 additions & 2 deletions apps/web/src/routers/admin/gastown-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const UserRigRecord = z.object({
const BeadRecord = z.object({
bead_id: z.string(),
type: z.enum(['issue', 'message', 'escalation', 'merge_request', 'convoy', 'molecule', 'agent']),
status: z.enum(['open', 'in_progress', 'closed', 'failed']),
status: z.enum(['open', 'in_progress', 'in_review', 'closed', 'failed']),
title: z.string(),
body: z.string().nullable(),
rig_id: z.string().nullable(),
Expand All @@ -51,6 +51,16 @@ const BeadRecord = z.object({
created_at: z.string(),
updated_at: z.string(),
closed_at: z.string().nullable(),
failure_reason: z
.object({
code: z.string(),
message: z.string(),
details: z.string().optional(),
source: z.string(),
})
.nullable()
.optional()
.default(null),
});

const AgentRecord = z.object({
Expand Down Expand Up @@ -506,7 +516,7 @@ export const adminGastownRouter = createTRPCRouter({
.input(
z.object({
townId: z.string().uuid(),
status: z.enum(['open', 'in_progress', 'closed', 'failed']).optional(),
status: z.enum(['open', 'in_progress', 'in_review', 'closed', 'failed']).optional(),
type: z
.enum(['issue', 'message', 'escalation', 'merge_request', 'convoy', 'molecule', 'agent'])
.optional(),
Expand Down
8 changes: 8 additions & 0 deletions services/gastown/src/dos/Town.do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1185,10 +1185,18 @@ export class TownDO extends DurableObject<Env> {
return beadOps.getBead(this.sql, beadId);
}

async getBeadWithFailureReason(beadId: string): Promise<beadOps.BeadWithFailureReason | null> {
return beadOps.getBeadWithFailureReason(this.sql, beadId);
}

async listBeads(filter: BeadFilter): Promise<Bead[]> {
return beadOps.listBeads(this.sql, filter);
}

async listBeadsWithFailureReasons(filter: BeadFilter): Promise<beadOps.BeadWithFailureReason[]> {
return beadOps.listBeadsWithFailureReasons(this.sql, filter);
}

async updateBeadStatus(
beadId: string,
status: string,
Expand Down
61 changes: 61 additions & 0 deletions services/gastown/src/dos/town/beads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ import type {
import type { BeadEventType } from '../../db/tables/bead-events.table';
import type { FailureReason } from './types';

export type BeadWithFailureReason = Bead & { failure_reason: FailureReason | null };

const FailureReasonMetadata = z.object({
failure_reason: z.object({
code: z.string(),
message: z.string(),
details: z.string().optional(),
source: z.string(),
}),
});

function generateId(): string {
return crypto.randomUUID();
}
Expand Down Expand Up @@ -263,6 +274,56 @@ export function listBeads(sql: SqlStorage, filter: BeadFilter): Bead[] {
return BeadRecord.array().parse(rows);
}

function extractFailureReason(metadata: Record<string, unknown>): FailureReason | null {
const parsed = FailureReasonMetadata.safeParse(metadata);
return parsed.success ? parsed.data.failure_reason : null;
}

function failureReasonForBead(sql: SqlStorage, beadId: string): FailureReason | null {
const events = BeadEventRecord.pick({ metadata: true })
.array()
.parse([
...query(
sql,
/* sql */ `
SELECT ${bead_events.metadata}
FROM ${bead_events}
WHERE ${bead_events.bead_id} = ?
AND ${bead_events.event_type} = 'status_changed'
AND ${bead_events.new_value} = 'failed'
ORDER BY ${bead_events.created_at} DESC
LIMIT 1
`,
[beadId]
),
]);

const event = events[0];
return event ? extractFailureReason(event.metadata) : null;
}

function attachFailureReason(sql: SqlStorage, bead: Bead): BeadWithFailureReason {
return {
...bead,
failure_reason: bead.status === 'failed' ? failureReasonForBead(sql, bead.bead_id) : null,
};
}

export function getBeadWithFailureReason(
sql: SqlStorage,
beadId: string
): BeadWithFailureReason | null {
const bead = getBead(sql, beadId);
return bead ? attachFailureReason(sql, bead) : null;
}

export function listBeadsWithFailureReasons(
sql: SqlStorage,
filter: BeadFilter
): BeadWithFailureReason[] {
return listBeads(sql, filter).map(bead => attachFailureReason(sql, bead));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: N+1 query pattern — one extra SQL query per bead

listBeadsWithFailureReasons calls attachFailureReason for every bead, and attachFailureReason calls failureReasonForBead which issues its own SQL query (SELECT metadata FROM bead_events WHERE bead_id = ? AND event_type = 'status_changed' AND new_value = 'failed' LIMIT 1). With the default limit of 200 beads, this results in up to 201 SQL queries for a single adminListBeads call.

This is a Durable Object SQLite context so the queries are local/in-process, but it still generates significant per-bead overhead. Consider a single bulk query joining beads with bead_events filtered to the latest failed status-change event per bead, using MAX(created_at) or a window function, to fetch all failure reasons in one pass.

}

export function updateBeadStatus(
sql: SqlStorage,
beadId: string,
Expand Down
11 changes: 7 additions & 4 deletions services/gastown/src/trpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1784,7 +1784,7 @@ export const gastownRouter = router({
.input(
z.object({
townId: z.string().uuid(),
status: z.enum(['open', 'in_progress', 'closed', 'failed']).optional(),
status: z.enum(['open', 'in_progress', 'in_review', 'closed', 'failed']).optional(),
type: z
.enum(['issue', 'message', 'escalation', 'merge_request', 'convoy', 'molecule', 'agent'])
.optional(),
Expand All @@ -1794,7 +1794,7 @@ export const gastownRouter = router({
.output(z.array(RpcBeadOutput))
.query(async ({ ctx, input }) => {
const townStub = getTownDOStub(ctx.env, input.townId);
return townStub.listBeads({
return townStub.listBeadsWithFailureReasons({
status: input.status,
type: input.type,
limit: input.limit,
Expand Down Expand Up @@ -1837,11 +1837,14 @@ export const gastownRouter = router({
.output(RpcBeadOutput)
.mutation(async ({ ctx, input }) => {
const townStub = getTownDOStub(ctx.env, input.townId);
return townStub.updateBeadStatus(input.beadId, 'failed', 'admin', {
await townStub.updateBeadStatus(input.beadId, 'failed', 'admin', {
code: 'admin_force_fail',
message: 'Manually failed by admin',
source: 'admin',
});
const bead = await townStub.getBeadWithFailureReason(input.beadId);
if (!bead) throw new TRPCError({ code: 'NOT_FOUND', message: 'Bead not found after update' });
return bead;
}),

adminGetAlarmStatus: adminProcedure
Expand Down Expand Up @@ -1876,7 +1879,7 @@ export const gastownRouter = router({
.output(RpcBeadOutput.nullable())
.query(async ({ ctx, input }) => {
const townStub = getTownDOStub(ctx.env, input.townId);
return townStub.getBeadAsync(input.beadId);
return townStub.getBeadWithFailureReason(input.beadId);
}),

adminBulkDeleteBeads: adminProcedure
Expand Down
8 changes: 8 additions & 0 deletions services/gastown/src/trpc/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ export const RigOutput = z.object({
});

// Bead (output shape, after transforms)
export const FailureReasonOutput = z.object({
code: z.string(),
message: z.string(),
details: z.string().optional(),
source: z.string(),
});

export const BeadOutput = z.object({
bead_id: z.string(),
type: z.enum(['issue', 'message', 'escalation', 'merge_request', 'convoy', 'molecule', 'agent']),
Expand All @@ -48,6 +55,7 @@ export const BeadOutput = z.object({
created_at: z.string(),
updated_at: z.string(),
closed_at: z.string().nullable(),
failure_reason: FailureReasonOutput.nullable().optional().default(null),
});

// Agent
Expand Down
20 changes: 20 additions & 0 deletions services/gastown/test/integration/rig-do.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,26 @@ describe('TownDO', () => {
expect(result).toBeNull();
});

it('should preserve failed bead reasons after later field updates', async () => {
const bead = await town.createBead({ type: 'issue', title: 'Failure reason test' });
const failureReason = {
code: 'test_failure',
message: 'The test failed',
details: 'regression coverage',
source: 'admin',
};

await town.updateBeadStatus(bead.bead_id, 'failed', 'system', failureReason);

for (let i = 0; i < 21; i++) {
await town.updateBead(bead.bead_id, { title: `Failure reason test ${i}` }, 'system');
}

const failedBead = await town.getBeadWithFailureReason(bead.bead_id);

expect(failedBead?.failure_reason).toEqual(failureReason);
});

it('should list beads with filters', async () => {
await town.createBead({ type: 'issue', title: 'Issue 1' });
await town.createBead({ type: 'message', title: 'Message 1' });
Expand Down