From f46be65a7f99040e7cf37b153c9e62eef877175d Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Thu, 12 Mar 2026 16:54:58 -0500 Subject: [PATCH 1/8] feat(gastown): add mayor edit capabilities and manual bead/agent/convoy editing Adds 7 new mayor edit tools (gt_bead_update, gt_bead_reassign, gt_agent_reset, gt_convoy_close, gt_bead_delete, gt_escalation_acknowledge, gt_bead_fail) with corresponding PATCH endpoints. Adds dashboard UI edit controls (status dropdowns, editable fields, unhook/reset buttons). Updates mayor system prompt with Surgical Editing section documenting the new capabilities. Closes #996 --- cloudflare-gastown/container/plugin/client.ts | 58 +++++ .../container/plugin/mayor-tools.test.ts | 97 +++++++ .../container/plugin/mayor-tools.ts | 113 ++++++++ .../src/db/tables/bead-events.table.ts | 1 + cloudflare-gastown/src/dos/Town.do.ts | 115 ++++++++ cloudflare-gastown/src/dos/town/beads.ts | 88 ++++++- cloudflare-gastown/src/gastown.worker.ts | 28 ++ .../src/handlers/mayor-tools.handler.ts | 234 ++++++++++++++++- .../src/prompts/mayor-system.prompt.ts | 13 + cloudflare-gastown/src/ui/dashboard.ui.ts | 245 ++++++++++++++++++ 10 files changed, 990 insertions(+), 2 deletions(-) diff --git a/cloudflare-gastown/container/plugin/client.ts b/cloudflare-gastown/container/plugin/client.ts index d539341117..124108c9bb 100644 --- a/cloudflare-gastown/container/plugin/client.ts +++ b/cloudflare-gastown/container/plugin/client.ts @@ -348,6 +348,64 @@ export class MayorGastownClient { async getConvoyStatus(convoyId: string): Promise { return this.request(this.mayorPath(`/convoys/${convoyId}`)); } + + async updateBead( + rigId: string, + beadId: string, + input: { + title?: string; + body?: string; + status?: 'open' | 'in_progress' | 'closed' | 'failed'; + priority?: 'low' | 'medium' | 'high' | 'critical'; + labels?: string[]; + } + ): Promise { + return this.request(this.mayorPath(`/rigs/${rigId}/beads/${beadId}`), { + method: 'PATCH', + body: JSON.stringify(input), + }); + } + + async reassignBead(rigId: string, beadId: string, agentId: string): Promise { + return this.request(this.mayorPath(`/rigs/${rigId}/beads/${beadId}/reassign`), { + method: 'POST', + body: JSON.stringify({ agent_id: agentId }), + }); + } + + async deleteBead(rigId: string, beadId: string): Promise { + await this.request(this.mayorPath(`/rigs/${rigId}/beads/${beadId}`), { + method: 'DELETE', + }); + } + + async resetAgent(rigId: string, agentId: string): Promise { + await this.request(this.mayorPath(`/rigs/${rigId}/agents/${agentId}/reset`), { + method: 'POST', + }); + } + + async closeConvoy(convoyId: string): Promise { + await this.request(this.mayorPath(`/convoys/${convoyId}/close`), { + method: 'POST', + }); + } + + async updateConvoy( + convoyId: string, + input: { merge_mode?: 'review-then-land' | 'review-and-merge'; feature_branch?: string } + ): Promise { + await this.request(this.mayorPath(`/convoys/${convoyId}`), { + method: 'PATCH', + body: JSON.stringify(input), + }); + } + + async acknowledgeEscalation(escalationId: string): Promise { + await this.request(this.mayorPath(`/escalations/${escalationId}/acknowledge`), { + method: 'POST', + }); + } } export class GastownApiError extends Error { diff --git a/cloudflare-gastown/container/plugin/mayor-tools.test.ts b/cloudflare-gastown/container/plugin/mayor-tools.test.ts index 13c3a245ce..4fd1197195 100644 --- a/cloudflare-gastown/container/plugin/mayor-tools.test.ts +++ b/cloudflare-gastown/container/plugin/mayor-tools.test.ts @@ -121,6 +121,13 @@ function makeFakeMayorClient(overrides: Partial = {}): Mayor }, ], }), + updateBead: vi.fn<() => Promise>().mockResolvedValue(FAKE_BEAD), + reassignBead: vi.fn<() => Promise>().mockResolvedValue(FAKE_BEAD), + deleteBead: vi.fn().mockResolvedValue(undefined), + resetAgent: vi.fn().mockResolvedValue(undefined), + closeConvoy: vi.fn().mockResolvedValue(undefined), + updateConvoy: vi.fn().mockResolvedValue(undefined), + acknowledgeEscalation: vi.fn().mockResolvedValue(undefined), ...overrides, } as unknown as MayorGastownClient; } @@ -245,4 +252,94 @@ describe('mayor tools', () => { expect(result).toContain('No rigs configured'); }); }); + + describe('gt_bead_update', () => { + it('updates bead fields and returns summary', async () => { + const result = await tools.gt_bead_update.execute( + { rig_id: 'rig-1', bead_id: 'bead-1', status: 'closed', priority: 'high' }, + CTX + ); + expect(result).toContain('bead-1'); + expect(result).toContain('updated'); + expect(client.updateBead).toHaveBeenCalledWith('rig-1', 'bead-1', { + title: undefined, + body: undefined, + status: 'closed', + priority: 'high', + labels: undefined, + }); + }); + }); + + describe('gt_bead_reassign', () => { + it('reassigns bead to new agent', async () => { + const result = await tools.gt_bead_reassign.execute( + { rig_id: 'rig-1', bead_id: 'bead-1', agent_id: 'agent-2' }, + CTX + ); + expect(result).toContain('bead-1'); + expect(result).toContain('agent-2'); + expect(client.reassignBead).toHaveBeenCalledWith('rig-1', 'bead-1', 'agent-2'); + }); + }); + + describe('gt_bead_delete', () => { + it('deletes bead and confirms', async () => { + const result = await tools.gt_bead_delete.execute( + { rig_id: 'rig-1', bead_id: 'bead-1' }, + CTX + ); + expect(result).toContain('bead-1'); + expect(result).toContain('deleted'); + expect(client.deleteBead).toHaveBeenCalledWith('rig-1', 'bead-1'); + }); + }); + + describe('gt_agent_reset', () => { + it('resets agent to idle', async () => { + const result = await tools.gt_agent_reset.execute( + { rig_id: 'rig-1', agent_id: 'agent-1' }, + CTX + ); + expect(result).toContain('agent-1'); + expect(result).toContain('idle'); + expect(client.resetAgent).toHaveBeenCalledWith('rig-1', 'agent-1'); + }); + }); + + describe('gt_convoy_close', () => { + it('force-closes a convoy', async () => { + const result = await tools.gt_convoy_close.execute({ convoy_id: 'convoy-1' }, CTX); + expect(result).toContain('convoy-1'); + expect(result).toContain('closed'); + expect(client.closeConvoy).toHaveBeenCalledWith('convoy-1'); + }); + }); + + describe('gt_convoy_update', () => { + it('updates convoy metadata', async () => { + const result = await tools.gt_convoy_update.execute( + { convoy_id: 'convoy-1', merge_mode: 'review-and-merge' }, + CTX + ); + expect(result).toContain('convoy-1'); + expect(result).toContain('updated'); + expect(client.updateConvoy).toHaveBeenCalledWith('convoy-1', { + merge_mode: 'review-and-merge', + feature_branch: undefined, + }); + }); + }); + + describe('gt_escalation_acknowledge', () => { + it('acknowledges an escalation', async () => { + const result = await tools.gt_escalation_acknowledge.execute( + { escalation_id: 'esc-1' }, + CTX + ); + expect(result).toContain('esc-1'); + expect(result).toContain('acknowledged'); + expect(client.acknowledgeEscalation).toHaveBeenCalledWith('esc-1'); + }); + }); }); diff --git a/cloudflare-gastown/container/plugin/mayor-tools.ts b/cloudflare-gastown/container/plugin/mayor-tools.ts index 714ba3a02b..aecfbe5fde 100644 --- a/cloudflare-gastown/container/plugin/mayor-tools.ts +++ b/cloudflare-gastown/container/plugin/mayor-tools.ts @@ -244,5 +244,118 @@ export function createMayorTools(client: MayorGastownClient) { return `Mail sent to agent ${args.to_agent_id} in rig ${args.rig_id}.`; }, }), + + gt_bead_update: tool({ + description: "Edit a bead's status, title, body, priority, or labels.", + args: { + rig_id: tool.schema.string().describe('The UUID of the rig the bead belongs to'), + bead_id: tool.schema.string().describe('The UUID of the bead to update'), + title: tool.schema.string().describe('New title for the bead').optional(), + body: tool.schema.string().describe('New body/description for the bead').optional(), + status: tool.schema + .enum(['open', 'in_progress', 'closed', 'failed']) + .describe('New status for the bead') + .optional(), + priority: tool.schema + .enum(['low', 'medium', 'high', 'critical']) + .describe('New priority for the bead') + .optional(), + labels: tool.schema + .array(tool.schema.string()) + .describe('Replacement labels array for the bead') + .optional(), + }, + async execute(args) { + const bead = await client.updateBead(args.rig_id, args.bead_id, { + title: args.title, + body: args.body, + status: args.status, + priority: args.priority, + labels: args.labels, + }); + return `Bead ${bead.bead_id} updated. Status: ${bead.status}, Priority: ${bead.priority}, Title: "${bead.title}".`; + }, + }), + + gt_bead_reassign: tool({ + description: 'Reassign a bead to a different agent.', + args: { + rig_id: tool.schema.string().describe('The UUID of the rig the bead belongs to'), + bead_id: tool.schema.string().describe('The UUID of the bead to reassign'), + agent_id: tool.schema.string().describe('The UUID of the agent to assign the bead to'), + }, + async execute(args) { + const bead = await client.reassignBead(args.rig_id, args.bead_id, args.agent_id); + return `Bead ${bead.bead_id} reassigned to agent ${args.agent_id}.`; + }, + }), + + gt_bead_delete: tool({ + description: 'Delete a bead. Use with caution — this is irreversible.', + args: { + rig_id: tool.schema.string().describe('The UUID of the rig the bead belongs to'), + bead_id: tool.schema.string().describe('The UUID of the bead to delete'), + }, + async execute(args) { + await client.deleteBead(args.rig_id, args.bead_id); + return `Bead ${args.bead_id} deleted.`; + }, + }), + + gt_agent_reset: tool({ + description: 'Force-reset an agent to idle, unhooking from any bead.', + args: { + rig_id: tool.schema.string().describe('The UUID of the rig the agent belongs to'), + agent_id: tool.schema.string().describe('The UUID of the agent to reset'), + }, + async execute(args) { + await client.resetAgent(args.rig_id, args.agent_id); + return `Agent ${args.agent_id} reset to idle.`; + }, + }), + + gt_convoy_close: tool({ + description: 'Force-close a convoy and optionally its tracked beads.', + args: { + convoy_id: tool.schema.string().describe('The UUID of the convoy to force-close'), + }, + async execute(args) { + await client.closeConvoy(args.convoy_id); + return `Convoy ${args.convoy_id} force-closed.`; + }, + }), + + gt_convoy_update: tool({ + description: 'Edit convoy metadata (merge_mode, feature_branch).', + args: { + convoy_id: tool.schema.string().describe('The UUID of the convoy to update'), + merge_mode: tool.schema + .enum(['review-then-land', 'review-and-merge']) + .describe('New merge mode for the convoy') + .optional(), + feature_branch: tool.schema + .string() + .describe('New feature branch name for the convoy') + .optional(), + }, + async execute(args) { + await client.updateConvoy(args.convoy_id, { + merge_mode: args.merge_mode, + feature_branch: args.feature_branch, + }); + return `Convoy ${args.convoy_id} updated.`; + }, + }), + + gt_escalation_acknowledge: tool({ + description: 'Acknowledge an escalation.', + args: { + escalation_id: tool.schema.string().describe('The UUID of the escalation to acknowledge'), + }, + async execute(args) { + await client.acknowledgeEscalation(args.escalation_id); + return `Escalation ${args.escalation_id} acknowledged.`; + }, + }), }; } diff --git a/cloudflare-gastown/src/db/tables/bead-events.table.ts b/cloudflare-gastown/src/db/tables/bead-events.table.ts index 72c6905544..e167c66d8d 100644 --- a/cloudflare-gastown/src/db/tables/bead-events.table.ts +++ b/cloudflare-gastown/src/db/tables/bead-events.table.ts @@ -19,6 +19,7 @@ export const BeadEventType = z.enum([ 'pr_creation_failed', 'agent_status', 'triage_resolved', + 'fields_updated', ]); export type BeadEventType = z.infer; diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index e94b902798..2d54037488 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -51,6 +51,8 @@ import type { CreateBeadInput, BeadFilter, Bead, + BeadStatus, + BeadPriority as BeadPriorityType, RegisterAgentInput, AgentFilter, Agent, @@ -64,6 +66,7 @@ import type { Molecule, BeadEventRecord, MergeStrategy, + ConvoyMergeMode, } from '../types'; const TOWN_LOG = '[Town.do]'; @@ -580,6 +583,118 @@ export class TownDO extends DurableObject { return beadOps.listBeadEvents(this.sql, options); } + /** + * Partially update a bead's editable fields. + * Only fields explicitly provided are updated (partial update semantics). + * Writes a `fields_updated` bead_event for auditability. + */ + async updateBead( + beadId: string, + fields: Partial<{ + title: string; + body: string | null; + priority: BeadPriorityType; + labels: string[]; + status: BeadStatus; + metadata: Record; + }>, + actorId: string + ): Promise { + await this.ensureInitialized(); + const bead = beadOps.updateBeadFields(this.sql, beadId, fields, actorId); + + // When a bead closes via field update, check for newly unblocked beads + if (fields.status === 'closed' || fields.status === 'failed') { + this.dispatchUnblockedBeads(beadId); + } + + return bead; + } + + /** + * Force-reset an agent to idle, unhooking from its current bead if any. + * Sets the bead status back to 'open' so it can be re-dispatched. + * Writes a bead_event for auditability. + */ + async resetAgent(agentId: string): Promise { + await this.ensureInitialized(); + + const agent = agents.getAgent(this.sql, agentId); + if (!agent) throw new Error(`Agent ${agentId} not found`); + + const hookedBeadId = agent.current_hook_bead_id; + + if (hookedBeadId) { + // Return the bead to 'open' so the scheduler can re-assign it + const bead = beadOps.getBead(this.sql, hookedBeadId); + if (bead && bead.status !== 'closed' && bead.status !== 'failed') { + beadOps.updateBeadStatus(this.sql, hookedBeadId, 'open', agentId); + } + + beadOps.logBeadEvent(this.sql, { + beadId: hookedBeadId, + agentId, + eventType: 'unhooked', + newValue: 'open', + metadata: { reason: 'agent_reset', actor: 'mayor' }, + }); + + agents.unhookBead(this.sql, agentId); + } + + agents.updateAgentStatus(this.sql, agentId, 'idle'); + + console.log(`${TOWN_LOG} resetAgent: reset agent=${agentId} hookedBead=${hookedBeadId ?? 'none'}`); + } + + /** + * Edit convoy_metadata fields (merge_mode, feature_branch). + * Returns the updated convoy, or null if not found. + */ + async updateConvoy( + convoyId: string, + fields: Partial<{ merge_mode: ConvoyMergeMode; feature_branch: string }> + ): Promise { + await this.ensureInitialized(); + + const convoy = this.getConvoy(convoyId); + if (!convoy) return null; + + const setClauses: string[] = []; + const values: unknown[] = []; + + if (fields.merge_mode !== undefined) { + setClauses.push(`${convoy_metadata.columns.merge_mode} = ?`); + values.push(fields.merge_mode); + } + if (fields.feature_branch !== undefined) { + setClauses.push(`${convoy_metadata.columns.feature_branch} = ?`); + values.push(fields.feature_branch); + } + + if (setClauses.length > 0) { + values.push(convoyId); + query( + this.sql, + /* sql */ `UPDATE ${convoy_metadata} SET ${setClauses.join(', ')} WHERE ${convoy_metadata.bead_id} = ?`, + values + ); + + // Also update the convoy bead's updated_at + query( + this.sql, + /* sql */ ` + UPDATE ${beads} + SET ${beads.columns.updated_at} = ? + WHERE ${beads.bead_id} = ? + `, + [now(), convoyId] + ); + } + + return this.getConvoy(convoyId); + } + // ══════════════════════════════════════════════════════════════════ // Agents // ══════════════════════════════════════════════════════════════════ diff --git a/cloudflare-gastown/src/dos/town/beads.ts b/cloudflare-gastown/src/dos/town/beads.ts index 90419cab96..9312980a76 100644 --- a/cloudflare-gastown/src/dos/town/beads.ts +++ b/cloudflare-gastown/src/dos/town/beads.ts @@ -32,7 +32,7 @@ import { migrateConvoyMetadata, } from '../../db/tables/convoy-metadata.table'; import { query } from '../../util/query.util'; -import type { CreateBeadInput, BeadFilter, Bead } from '../../types'; +import type { CreateBeadInput, BeadFilter, Bead, BeadStatus, BeadPriority } from '../../types'; import type { BeadEventType } from '../../db/tables/bead-events.table'; function generateId(): string { @@ -442,6 +442,92 @@ export function getNewlyUnblockedBeads(sql: SqlStorage, closedBeadId: string): s return dependentIds.filter(id => !hasUnresolvedBlockers(sql, id)); } +/** + * Partial update of a bead's editable fields. + * Only fields explicitly provided in `fields` are updated. + * Writes a `fields_updated` bead_event for auditability. + */ +export function updateBeadFields( + sql: SqlStorage, + beadId: string, + fields: Partial<{ + title: string; + body: string | null; + priority: BeadPriority; + labels: string[]; + status: BeadStatus; + metadata: Record; + }>, + actorId: string +): Bead { + const bead = getBead(sql, beadId); + if (!bead) throw new Error(`Bead ${beadId} not found`); + + const timestamp = now(); + const setClauses: string[] = []; + const values: unknown[] = []; + + if (fields.title !== undefined) { + setClauses.push(`${beads.columns.title} = ?`); + values.push(fields.title); + } + if (fields.body !== undefined) { + setClauses.push(`${beads.columns.body} = ?`); + values.push(fields.body); + } + if (fields.priority !== undefined) { + setClauses.push(`${beads.columns.priority} = ?`); + values.push(fields.priority); + } + if (fields.labels !== undefined) { + setClauses.push(`${beads.columns.labels} = ?`); + values.push(JSON.stringify(fields.labels)); + } + if (fields.status !== undefined) { + setClauses.push(`${beads.columns.status} = ?`); + values.push(fields.status); + // Also set closed_at when transitioning to closed + if (fields.status === 'closed') { + setClauses.push(`${beads.columns.closed_at} = ?`); + values.push(bead.closed_at ?? timestamp); + } + } + if (fields.metadata !== undefined) { + setClauses.push(`${beads.columns.metadata} = ?`); + values.push(JSON.stringify(fields.metadata)); + } + + if (setClauses.length === 0) return bead; + + setClauses.push(`${beads.columns.updated_at} = ?`); + values.push(timestamp); + values.push(beadId); + + query( + sql, + /* sql */ `UPDATE ${beads} SET ${setClauses.join(', ')} WHERE ${beads.bead_id} = ?`, + values + ); + + const changedFields = Object.keys(fields); + logBeadEvent(sql, { + beadId, + agentId: actorId, + eventType: 'fields_updated', + newValue: changedFields.join(','), + metadata: { changed: changedFields, actor: actorId }, + }); + + // If status was updated to a terminal value, run convoy progress logic + if (fields.status === 'closed' || fields.status === 'failed') { + updateConvoyProgress(sql, beadId, timestamp); + } + + const updated = getBead(sql, beadId); + if (!updated) throw new Error(`Bead ${beadId} not found after update`); + return updated; +} + export function closeBead(sql: SqlStorage, beadId: string, agentId: string): Bead { return updateBeadStatus(sql, beadId, 'closed', agentId); } diff --git a/cloudflare-gastown/src/gastown.worker.ts b/cloudflare-gastown/src/gastown.worker.ts index 11520c7a85..b714454fac 100644 --- a/cloudflare-gastown/src/gastown.worker.ts +++ b/cloudflare-gastown/src/gastown.worker.ts @@ -85,6 +85,13 @@ import { handleMayorSendMail, handleMayorListConvoys, handleMayorConvoyStatus, + handleMayorBeadUpdate, + handleMayorBeadReassign, + handleMayorAgentReset, + handleMayorConvoyClose, + handleMayorConvoyUpdate, + handleMayorBeadDelete, + handleMayorEscalationAcknowledge, } from './handlers/mayor-tools.handler'; import { mayorAuthMiddleware } from './middleware/mayor-auth.middleware'; import { handleGetTownConfig, handleUpdateTownConfig } from './handlers/town-config.handler'; @@ -411,6 +418,27 @@ app.get('/api/mayor/:townId/tools/convoys', c => handleMayorListConvoys(c, c.req app.get('/api/mayor/:townId/tools/convoys/:convoyId', c => handleMayorConvoyStatus(c, c.req.param()) ); +app.patch('/api/mayor/:townId/tools/rigs/:rigId/beads/:beadId', c => + handleMayorBeadUpdate(c, c.req.param()) +); +app.post('/api/mayor/:townId/tools/rigs/:rigId/beads/:beadId/reassign', c => + handleMayorBeadReassign(c, c.req.param()) +); +app.delete('/api/mayor/:townId/tools/rigs/:rigId/beads/:beadId', c => + handleMayorBeadDelete(c, c.req.param()) +); +app.post('/api/mayor/:townId/tools/rigs/:rigId/agents/:agentId/reset', c => + handleMayorAgentReset(c, c.req.param()) +); +app.patch('/api/mayor/:townId/tools/convoys/:convoyId', c => + handleMayorConvoyUpdate(c, c.req.param()) +); +app.post('/api/mayor/:townId/tools/convoys/:convoyId/close', c => + handleMayorConvoyClose(c, c.req.param()) +); +app.post('/api/mayor/:townId/tools/escalations/:escalationId/acknowledge', c => + handleMayorEscalationAcknowledge(c, c.req.param()) +); // ── tRPC ──────────────────────────────────────────────────────────────── // Serve the gastown tRPC router directly. The frontend tRPC client diff --git a/cloudflare-gastown/src/handlers/mayor-tools.handler.ts b/cloudflare-gastown/src/handlers/mayor-tools.handler.ts index 67c9d67620..6ec67e3efc 100644 --- a/cloudflare-gastown/src/handlers/mayor-tools.handler.ts +++ b/cloudflare-gastown/src/handlers/mayor-tools.handler.ts @@ -4,7 +4,7 @@ import { getTownDOStub } from '../dos/Town.do'; import { getGastownUserStub } from '../dos/GastownUser.do'; import { resSuccess, resError } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { BeadStatus, BeadType } from '../types'; +import { BeadStatus, BeadType, BeadPriority } from '../types'; import type { GastownEnv } from '../gastown.worker'; const HANDLER_LOG = '[mayor-tools.handler]'; @@ -328,3 +328,235 @@ export async function handleMayorConvoyStatus( if (!status) return c.json(resError('Convoy not found'), 404); return c.json(resSuccess(status)); } + +// ── Edit operation schemas ──────────────────────────────────────────────── + +const BeadUpdateBody = z + .object({ + title: z.string().min(1).optional(), + body: z.string().nullable().optional(), + priority: BeadPriority.optional(), + labels: z.array(z.string()).optional(), + status: BeadStatus.optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + }) + .refine( + data => + data.title !== undefined || + data.body !== undefined || + data.priority !== undefined || + data.labels !== undefined || + data.status !== undefined || + data.metadata !== undefined, + { message: 'At least one field must be provided' } + ); + +const BeadReassignBody = z.object({ + agent_id: z.string().min(1), +}); + +const ConvoyUpdateBody = z + .object({ + merge_mode: z.enum(['review-then-land', 'review-and-merge']).optional(), + feature_branch: z.string().min(1).optional(), + }) + .refine(data => data.merge_mode !== undefined || data.feature_branch !== undefined, { + message: 'At least one field must be provided', + }); + +// ── Edit handlers ───────────────────────────────────────────────────────── + +/** + * PATCH /api/mayor/:townId/tools/rigs/:rigId/beads/:beadId + * Partially update a bead's editable fields (title, body, priority, labels, status, metadata). + */ +export async function handleMayorBeadUpdate( + c: Context, + params: { townId: string; rigId: string; beadId: string } +) { + const rigOwned = await verifyRigBelongsToTown(c, params.townId, params.rigId); + if (!rigOwned) { + return c.json(resError('Rig not found in this town'), 403); + } + + const parsed = BeadUpdateBody.safeParse(await parseJsonBody(c)); + if (!parsed.success) { + return c.json( + { success: false, error: 'Invalid request body', issues: parsed.error.issues }, + 400 + ); + } + + console.log( + `${HANDLER_LOG} handleMayorBeadUpdate: townId=${params.townId} rigId=${params.rigId} beadId=${params.beadId}` + ); + + const town = getTownDOStub(c.env, params.townId); + const bead = await town.updateBead(params.beadId, parsed.data, 'mayor'); + + return c.json(resSuccess(bead)); +} + +/** + * POST /api/mayor/:townId/tools/rigs/:rigId/beads/:beadId/reassign + * Reassign a bead to a different agent. Unhooks the current agent (if any) + * and hooks the specified agent. + */ +export async function handleMayorBeadReassign( + c: Context, + params: { townId: string; rigId: string; beadId: string } +) { + const rigOwned = await verifyRigBelongsToTown(c, params.townId, params.rigId); + if (!rigOwned) { + return c.json(resError('Rig not found in this town'), 403); + } + + const parsed = BeadReassignBody.safeParse(await parseJsonBody(c)); + if (!parsed.success) { + return c.json( + { success: false, error: 'Invalid request body', issues: parsed.error.issues }, + 400 + ); + } + + console.log( + `${HANDLER_LOG} handleMayorBeadReassign: townId=${params.townId} rigId=${params.rigId} beadId=${params.beadId} targetAgent=${parsed.data.agent_id}` + ); + + const town = getTownDOStub(c.env, params.townId); + + // Unhook any currently assigned agent + const bead = await town.getBeadAsync(params.beadId); + if (!bead) { + return c.json(resError('Bead not found'), 404); + } + if (bead.assignee_agent_bead_id) { + await town.unhookBead(bead.assignee_agent_bead_id); + } + + // Hook the new agent + await town.hookBead(parsed.data.agent_id, params.beadId); + + return c.json(resSuccess({ reassigned: true })); +} + +/** + * POST /api/mayor/:townId/tools/rigs/:rigId/agents/:agentId/reset + * Force-reset an agent to idle, unhooking from any current bead. + */ +export async function handleMayorAgentReset( + c: Context, + params: { townId: string; rigId: string; agentId: string } +) { + const rigOwned = await verifyRigBelongsToTown(c, params.townId, params.rigId); + if (!rigOwned) { + return c.json(resError('Rig not found in this town'), 403); + } + + console.log( + `${HANDLER_LOG} handleMayorAgentReset: townId=${params.townId} rigId=${params.rigId} agentId=${params.agentId}` + ); + + const town = getTownDOStub(c.env, params.townId); + await town.resetAgent(params.agentId); + + return c.json(resSuccess({ reset: true })); +} + +/** + * POST /api/mayor/:townId/tools/convoys/:convoyId/close + * Force-close a convoy and all its tracked open beads. + */ +export async function handleMayorConvoyClose( + c: Context, + params: { townId: string; convoyId: string } +) { + console.log( + `${HANDLER_LOG} handleMayorConvoyClose: townId=${params.townId} convoyId=${params.convoyId}` + ); + + const town = getTownDOStub(c.env, params.townId); + const convoy = await town.closeConvoy(params.convoyId); + + if (!convoy) return c.json(resError('Convoy not found'), 404); + return c.json(resSuccess(convoy)); +} + +/** + * PATCH /api/mayor/:townId/tools/convoys/:convoyId + * Edit convoy metadata (merge_mode or feature_branch). + */ +export async function handleMayorConvoyUpdate( + c: Context, + params: { townId: string; convoyId: string } +) { + const parsed = ConvoyUpdateBody.safeParse(await parseJsonBody(c)); + if (!parsed.success) { + return c.json( + { success: false, error: 'Invalid request body', issues: parsed.error.issues }, + 400 + ); + } + + console.log( + `${HANDLER_LOG} handleMayorConvoyUpdate: townId=${params.townId} convoyId=${params.convoyId}` + ); + + const town = getTownDOStub(c.env, params.townId); + const convoy = await town.updateConvoy(params.convoyId, parsed.data); + + if (!convoy) return c.json(resError('Convoy not found'), 404); + return c.json(resSuccess(convoy)); +} + +/** + * DELETE /api/mayor/:townId/tools/rigs/:rigId/beads/:beadId + * Delete a bead that belongs to the specified rig. + */ +export async function handleMayorBeadDelete( + c: Context, + params: { townId: string; rigId: string; beadId: string } +) { + const rigOwned = await verifyRigBelongsToTown(c, params.townId, params.rigId); + if (!rigOwned) { + return c.json(resError('Rig not found in this town'), 403); + } + + console.log( + `${HANDLER_LOG} handleMayorBeadDelete: townId=${params.townId} rigId=${params.rigId} beadId=${params.beadId}` + ); + + const town = getTownDOStub(c.env, params.townId); + + // Verify the bead belongs to this rig + const bead = await town.getBeadAsync(params.beadId); + if (!bead) { + return c.json(resError('Bead not found'), 404); + } + if (bead.rig_id !== params.rigId) { + return c.json(resError('Bead does not belong to this rig'), 403); + } + + await town.deleteBead(params.beadId); + + return c.json(resSuccess({ deleted: true })); +} + +/** + * POST /api/mayor/:townId/tools/escalations/:escalationId/acknowledge + * Acknowledge an escalation, marking it as reviewed. + */ +export async function handleMayorEscalationAcknowledge( + c: Context, + params: { townId: string; escalationId: string } +) { + console.log( + `${HANDLER_LOG} handleMayorEscalationAcknowledge: townId=${params.townId} escalationId=${params.escalationId}` + ); + + const town = getTownDOStub(c.env, params.townId); + const escalation = await town.acknowledgeEscalation(params.escalationId); + + if (!escalation) return c.json(resError('Escalation not found'), 404); + return c.json(resSuccess(escalation)); +} diff --git a/cloudflare-gastown/src/prompts/mayor-system.prompt.ts b/cloudflare-gastown/src/prompts/mayor-system.prompt.ts index db25815191..9def1750ed 100644 --- a/cloudflare-gastown/src/prompts/mayor-system.prompt.ts +++ b/cloudflare-gastown/src/prompts/mayor-system.prompt.ts @@ -203,6 +203,19 @@ You may READ files to build context. You must NEVER WRITE files. The browse work The only exception is running \`git pull\` in a browse directory to get the latest code. +## Surgical Editing + +You can directly edit town state when things go wrong: +- **gt_bead_update** to change bead status, title, body, or priority +- **gt_bead_reassign** to move a bead to a different agent +- **gt_agent_reset** to force-reset a stuck agent to idle +- **gt_convoy_close** to force-close a stuck convoy +- **gt_convoy_update** to edit convoy merge_mode or feature_branch +- **gt_bead_delete** to remove beads that shouldn't exist +- **gt_escalation_acknowledge** to acknowledge escalations + +Use these tools when the user reports stuck state, when you detect problems during delegation, or when you need to clean up after failures. You are the town coordinator — you have full authority over the control plane. + ## Important - You maintain context across messages. This is a continuous conversation. diff --git a/cloudflare-gastown/src/ui/dashboard.ui.ts b/cloudflare-gastown/src/ui/dashboard.ui.ts index dc414b5da4..ec8598ee86 100644 --- a/cloudflare-gastown/src/ui/dashboard.ui.ts +++ b/cloudflare-gastown/src/ui/dashboard.ui.ts @@ -53,6 +53,10 @@ export function dashboardHtml(): string { .badge.working { background: #d29922aa; color: #e3b341; } .badge.blocked { background: #f8514933; color: #f85149; } .badge.dead { background: #f8514966; color: #f85149; } + textarea.body-edit { background: #161b22; border: 1px solid #30363d; color: #c9d1d9; + padding: 4px 8px; border-radius: 4px; font-family: inherit; font-size: 12px; + width: 100%; min-height: 80px; resize: vertical; } + textarea.body-edit:focus { border-color: #58a6ff; outline: none; } .empty { color: #484f58; font-style: italic; } #toast { position: fixed; bottom: 16px; right: 16px; background: #1f6feb; color: #fff; padding: 8px 16px; border-radius: 6px; font-size: 12px; @@ -198,6 +202,87 @@ export function dashboardHtml(): string { + +
+

Mayor Edit Controls

+
+ + + + +
+ +

Bead Edit

+
+ + + + +
+
+ + +
+
+ + +
+
+ + + + + +
+
+ + + + +
+
+ +

Agent Controls

+
+ + + + +
+
+ + +
+
+ +

Convoy Controls

+
+ + + + + + +
+
+
+

Town Container

@@ -361,6 +446,166 @@ async function api(method, path, body) { function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } +// ── Mayor API helper ───────────────────────────────────────────────── + +function mayorToken() { return el('mayorToken').value.trim(); } +function mayorTownId() { return el('mayorTownId').value.trim(); } + +async function mayorApi(method, path, body) { + const log = el('apiLog'); + const token = mayorToken(); + const opts = { + method, + headers: { + 'Content-Type': 'application/json', + ...(token ? { 'Authorization': 'Bearer ' + token } : {}), + }, + }; + if (body !== undefined) opts.body = JSON.stringify(body); + + const tag = method + ' ' + path; + log.innerHTML += '' + esc(tag) + '\\n'; + + try { + const res = await fetch(path, opts); + const data = await res.json(); + const cls = res.ok ? 'ok' : 'err'; + log.innerHTML += '' + res.status + ' ' + + esc(JSON.stringify(data, null, 2)) + '\\n\\n'; + log.scrollTop = log.scrollHeight; + if (!res.ok) toast(data.error || res.status, true); + return { ok: res.ok, status: res.status, data }; + } catch (e) { + log.innerHTML += 'FETCH ERROR: ' + esc(e.message) + '\\n\\n'; + toast(e.message, true); + return { ok: false, status: 0, data: null }; + } +} + +// ── Mayor: Bead edit ───────────────────────────────────────────────── + +async function mayorBeadSave() { + const tId = mayorTownId(); + const rId = el('editBeadRigId').value.trim(); + const bId = el('editBeadId').value.trim(); + if (!tId || !rId || !bId) { toast('Fill in Town ID, Rig ID, and Bead ID', true); return; } + const body = {}; + const title = el('editBeadTitle').value.trim(); + const bodyText = el('editBeadBody').value.trim(); + const status = el('editBeadStatus').value; + const priority = el('editBeadPriority').value; + if (title) body.title = title; + if (bodyText) body.body = bodyText; + if (status) body.status = status; + if (priority) body.priority = priority; + if (!Object.keys(body).length) { toast('Provide at least one field to update', true); return; } + const r = await mayorApi('PATCH', '/api/mayor/' + tId + '/tools/rigs/' + rId + '/beads/' + bId, body); + if (r.ok) { + el('editBeadResult').innerHTML = '

Bead updated

'; + toast('Bead saved'); + await loadBeads(); + } else { + el('editBeadResult').innerHTML = '

Error: ' + esc(r.data?.error ?? 'unknown') + '

'; + } +} + +async function mayorBeadReassign() { + const tId = mayorTownId(); + const rId = el('editBeadRigId').value.trim(); + const bId = el('editBeadId').value.trim(); + const agentId = el('editBeadReassignAgent').value.trim(); + if (!tId || !rId || !bId || !agentId) { toast('Fill in Town ID, Rig ID, Bead ID, and Agent ID', true); return; } + const r = await mayorApi('POST', '/api/mayor/' + tId + '/tools/rigs/' + rId + '/beads/' + bId + '/reassign', { agent_id: agentId }); + if (r.ok) { + el('editBeadResult').innerHTML = '

Bead reassigned

'; + toast('Bead reassigned'); + await loadBeads(); + } else { + el('editBeadResult').innerHTML = '

Error: ' + esc(r.data?.error ?? 'unknown') + '

'; + } +} + +async function mayorBeadDelete() { + const tId = mayorTownId(); + const rId = el('editBeadRigId').value.trim(); + const bId = el('editBeadId').value.trim(); + if (!tId || !rId || !bId) { toast('Fill in Town ID, Rig ID, and Bead ID', true); return; } + if (!confirm('Delete bead ' + bId + '? This cannot be undone.')) return; + const r = await mayorApi('DELETE', '/api/mayor/' + tId + '/tools/rigs/' + rId + '/beads/' + bId); + if (r.ok) { + el('editBeadResult').innerHTML = '

Bead deleted

'; + el('editBeadId').value = ''; + toast('Bead deleted'); + await loadBeads(); + } else { + el('editBeadResult').innerHTML = '

Error: ' + esc(r.data?.error ?? 'unknown') + '

'; + } +} + +// ── Mayor: Agent controls ──────────────────────────────────────────── + +async function mayorAgentReset() { + const tId = mayorTownId(); + const rId = el('editAgentRigId').value.trim(); + const aId = el('editAgentId').value.trim(); + if (!tId || !rId || !aId) { toast('Fill in Town ID, Rig ID, and Agent ID', true); return; } + const r = await mayorApi('POST', '/api/mayor/' + tId + '/tools/rigs/' + rId + '/agents/' + aId + '/reset', {}); + if (r.ok) { + el('editAgentResult').innerHTML = '

Agent reset to idle

'; + toast('Agent reset'); + await loadAgents(); + } else { + el('editAgentResult').innerHTML = '

Error: ' + esc(r.data?.error ?? 'unknown') + '

'; + } +} + +async function mayorAgentUnhook() { + const tId = mayorTownId(); + const rId = el('editAgentRigId').value.trim(); + const aId = el('editAgentId').value.trim(); + if (!rId || !aId) { toast('Fill in Rig ID and Agent ID', true); return; } + const r = await api('DELETE', '/api/rigs/' + rId + '/agents/' + aId + '/hook'); + if (r.ok) { + el('editAgentResult').innerHTML = '

Agent unhooked

'; + toast('Agent unhooked'); + await loadAgents(); + } else { + el('editAgentResult').innerHTML = '

Error: ' + esc(r.data?.error ?? 'unknown') + '

'; + } +} + +// ── Mayor: Convoy controls ─────────────────────────────────────────── + +async function mayorConvoyUpdate() { + const tId = mayorTownId(); + const cId = el('editConvoyId').value.trim(); + if (!tId || !cId) { toast('Fill in Town ID and Convoy ID', true); return; } + const merge_mode = el('editConvoyMergeMode').value; + if (!merge_mode) { toast('Select a merge mode to update', true); return; } + const r = await mayorApi('PATCH', '/api/mayor/' + tId + '/tools/convoys/' + cId, { merge_mode }); + if (r.ok) { + el('editConvoyResult').innerHTML = '

Convoy updated

'; + toast('Convoy updated'); + } else { + el('editConvoyResult').innerHTML = '

Error: ' + esc(r.data?.error ?? 'unknown') + '

'; + } +} + +async function mayorConvoyClose() { + const tId = mayorTownId(); + const cId = el('editConvoyId').value.trim(); + if (!tId || !cId) { toast('Fill in Town ID and Convoy ID', true); return; } + if (!confirm('Force-close convoy ' + cId + ' and all its open beads?')) return; + const r = await mayorApi('POST', '/api/mayor/' + tId + '/tools/convoys/' + cId + '/close', {}); + if (r.ok) { + el('editConvoyResult').innerHTML = '

Convoy force-closed

'; + toast('Convoy closed'); + await loadBeads(); + } else { + el('editConvoyResult').innerHTML = '

Error: ' + esc(r.data?.error ?? 'unknown') + '

'; + } +} + function toast(msg, isErr) { const t = el('toast'); t.textContent = msg; From 1291e331f302c875fb016bb248de445472c7b0d1 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Fri, 13 Mar 2026 10:30:10 -0500 Subject: [PATCH 2/8] feat(gastown): make all bead fields user-editable in dashboard UI and API Expand the PATCH /beads/:id endpoint to accept type, rig_id, and parent_bead_id in addition to existing fields. Add corresponding updateBeadFields support. Dashboard UI gains: labels input (comma- separated), metadata textarea (JSON), type/rig/parent dropdowns, in_review status option, Load button to populate the form from an existing bead, and an Edit button in the beads table for one-click editing. Also adds priority column to the beads table and failed badge style. --- cloudflare-gastown/src/dos/town/beads.ts | 24 ++++- .../src/handlers/mayor-tools.handler.ts | 8 +- cloudflare-gastown/src/ui/dashboard.ui.ts | 99 ++++++++++++++++++- 3 files changed, 127 insertions(+), 4 deletions(-) diff --git a/cloudflare-gastown/src/dos/town/beads.ts b/cloudflare-gastown/src/dos/town/beads.ts index 9312980a76..8f6a975403 100644 --- a/cloudflare-gastown/src/dos/town/beads.ts +++ b/cloudflare-gastown/src/dos/town/beads.ts @@ -32,7 +32,14 @@ import { migrateConvoyMetadata, } from '../../db/tables/convoy-metadata.table'; import { query } from '../../util/query.util'; -import type { CreateBeadInput, BeadFilter, Bead, BeadStatus, BeadPriority } from '../../types'; +import type { + CreateBeadInput, + BeadFilter, + Bead, + BeadStatus, + BeadPriority, + BeadType, +} from '../../types'; import type { BeadEventType } from '../../db/tables/bead-events.table'; function generateId(): string { @@ -457,6 +464,9 @@ export function updateBeadFields( labels: string[]; status: BeadStatus; metadata: Record; + type: BeadType; + rig_id: string | null; + parent_bead_id: string | null; }>, actorId: string ): Bead { @@ -496,6 +506,18 @@ export function updateBeadFields( setClauses.push(`${beads.columns.metadata} = ?`); values.push(JSON.stringify(fields.metadata)); } + if (fields.type !== undefined) { + setClauses.push(`${beads.columns.type} = ?`); + values.push(fields.type); + } + if (fields.rig_id !== undefined) { + setClauses.push(`${beads.columns.rig_id} = ?`); + values.push(fields.rig_id); + } + if (fields.parent_bead_id !== undefined) { + setClauses.push(`${beads.columns.parent_bead_id} = ?`); + values.push(fields.parent_bead_id); + } if (setClauses.length === 0) return bead; diff --git a/cloudflare-gastown/src/handlers/mayor-tools.handler.ts b/cloudflare-gastown/src/handlers/mayor-tools.handler.ts index 6ec67e3efc..bc81bf1519 100644 --- a/cloudflare-gastown/src/handlers/mayor-tools.handler.ts +++ b/cloudflare-gastown/src/handlers/mayor-tools.handler.ts @@ -339,6 +339,9 @@ const BeadUpdateBody = z labels: z.array(z.string()).optional(), status: BeadStatus.optional(), metadata: z.record(z.string(), z.unknown()).optional(), + type: BeadType.optional(), + rig_id: z.string().min(1).nullable().optional(), + parent_bead_id: z.string().min(1).nullable().optional(), }) .refine( data => @@ -347,7 +350,10 @@ const BeadUpdateBody = z data.priority !== undefined || data.labels !== undefined || data.status !== undefined || - data.metadata !== undefined, + data.metadata !== undefined || + data.type !== undefined || + data.rig_id !== undefined || + data.parent_bead_id !== undefined, { message: 'At least one field must be provided' } ); diff --git a/cloudflare-gastown/src/ui/dashboard.ui.ts b/cloudflare-gastown/src/ui/dashboard.ui.ts index ec8598ee86..703c515bf5 100644 --- a/cloudflare-gastown/src/ui/dashboard.ui.ts +++ b/cloudflare-gastown/src/ui/dashboard.ui.ts @@ -49,6 +49,7 @@ export function dashboardHtml(): string { .badge.in_progress { background: #d29922aa; color: #e3b341; } .badge.in_review { background: #8957e533; color: #bc8cff; } .badge.closed { background: #3fb95033; color: #3fb950; } + .badge.failed { background: #f8514933; color: #f85149; } .badge.idle { background: #21262d; color: #8b949e; } .badge.working { background: #d29922aa; color: #e3b341; } .badge.blocked { background: #f8514933; color: #f85149; } @@ -218,6 +219,7 @@ export function dashboardHtml(): string { +
@@ -233,6 +235,7 @@ export function dashboardHtml(): string { + @@ -244,9 +247,34 @@ export function dashboardHtml(): string { -
+ + + + + + +
+
+ + +
+
+ + +
+
+ @@ -494,10 +522,25 @@ async function mayorBeadSave() { const bodyText = el('editBeadBody').value.trim(); const status = el('editBeadStatus').value; const priority = el('editBeadPriority').value; + const beadType = el('editBeadType').value; + const rigIdField = el('editBeadRigIdField').value.trim(); + const parentId = el('editBeadParentId').value.trim(); + const labelsRaw = el('editBeadLabels').value.trim(); + const metadataRaw = el('editBeadMetadata').value.trim(); if (title) body.title = title; if (bodyText) body.body = bodyText; if (status) body.status = status; if (priority) body.priority = priority; + if (beadType) body.type = beadType; + if (rigIdField) body.rig_id = rigIdField; + if (parentId) body.parent_bead_id = parentId; + if (labelsRaw) { + body.labels = labelsRaw.split(',').map(function(l) { return l.trim(); }).filter(Boolean); + } + if (metadataRaw) { + try { body.metadata = JSON.parse(metadataRaw); } + catch { toast('Invalid JSON in metadata field', true); return; } + } if (!Object.keys(body).length) { toast('Provide at least one field to update', true); return; } const r = await mayorApi('PATCH', '/api/mayor/' + tId + '/tools/rigs/' + rId + '/beads/' + bId, body); if (r.ok) { @@ -509,6 +552,32 @@ async function mayorBeadSave() { } } +async function mayorBeadLoad() { + const tId = mayorTownId(); + const rId = el('editBeadRigId').value.trim(); + const bId = el('editBeadId').value.trim(); + if (!tId || !rId || !bId) { toast('Fill in Town ID, Rig ID, and Bead ID', true); return; } + const r = await mayorApi('GET', '/api/mayor/' + tId + '/tools/rigs/' + rId + '/beads?limit=200'); + if (!r.ok) { toast('Failed to load beads', true); return; } + const match = (r.data.data || []).find(function(b) { return b.bead_id === bId || b.id === bId; }); + if (!match) { toast('Bead not found in rig', true); return; } + el('editBeadTitle').value = match.title || ''; + el('editBeadBody').value = match.body || ''; + el('editBeadStatus').value = match.status || ''; + el('editBeadPriority').value = match.priority || ''; + el('editBeadType').value = match.type || ''; + el('editBeadRigIdField').value = match.rig_id || ''; + el('editBeadParentId').value = match.parent_bead_id || ''; + var labels = match.labels; + if (typeof labels === 'string') { try { labels = JSON.parse(labels); } catch {} } + el('editBeadLabels').value = Array.isArray(labels) ? labels.join(', ') : ''; + var meta = match.metadata; + if (typeof meta === 'string') { try { meta = JSON.parse(meta); } catch {} } + el('editBeadMetadata').value = (meta && typeof meta === 'object' && Object.keys(meta).length > 0) + ? JSON.stringify(meta, null, 2) : ''; + toast('Bead loaded'); +} + async function mayorBeadReassign() { const tId = mayorTownId(); const rId = el('editBeadRigId').value.trim(); @@ -685,15 +754,17 @@ async function loadBeads() { function renderBeads() { if (!beads.length) { el('beadsList').innerHTML = '

No beads

'; return; } - let h = ''; + let h = '
IDTitleTypeStatusAssignee
'; for (const b of beads) { h += '' + '' + '' + '' + '' + + '' + '' + '' + ''; @@ -702,6 +773,30 @@ function renderBeads() { el('beadsList').innerHTML = h; } +function editBead(beadId) { + const b = beads.find(function(bead) { return bead.id === beadId || bead.bead_id === beadId; }); + if (!b) { toast('Bead not found', true); return; } + el('editBeadId').value = beadId; + el('editBeadRigId').value = b.rig_id || rigId(); + el('editBeadTitle').value = b.title || ''; + el('editBeadBody').value = b.body || ''; + el('editBeadStatus').value = b.status || ''; + el('editBeadPriority').value = b.priority || ''; + el('editBeadType').value = b.type || ''; + el('editBeadRigIdField').value = b.rig_id || ''; + el('editBeadParentId').value = b.parent_bead_id || ''; + var labels = b.labels; + if (typeof labels === 'string') { try { labels = JSON.parse(labels); } catch {} } + el('editBeadLabels').value = Array.isArray(labels) ? labels.join(', ') : ''; + var meta = b.metadata; + if (typeof meta === 'string') { try { meta = JSON.parse(meta); } catch {} } + el('editBeadMetadata').value = (meta && typeof meta === 'object' && Object.keys(meta).length > 0) + ? JSON.stringify(meta, null, 2) : ''; + // Scroll to the edit section + el('editBeadTitle').scrollIntoView({ behavior: 'smooth', block: 'center' }); + toast('Editing bead ' + short(beadId)); +} + async function createBead() { if (!rigId()) { toast('Set a Rig ID first', true); return; } const title = el('beadTitle').value.trim(); From b9d7303dec0cccaa089f999ff08fdc8e0a90a58d Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Fri, 13 Mar 2026 10:40:22 -0500 Subject: [PATCH 3/8] feat(gastown): add bead editing UI to BeadPanel drawer Add updateBead tRPC mutation to the gastown worker router accepting all editable bead fields (title, body, status, priority, type, labels, metadata, rig_id, parent_bead_id). The BeadPanel drawer now has a pencil icon toggle that switches to edit mode: title becomes an input, status/ type/priority become selects, and labels/body/metadata/rig_id/parent fields appear as additional inputs. Save computes a diff against the original bead and only sends changed fields. Regenerated router.d.ts type declarations. --- cloudflare-gastown/src/trpc/router.ts | 48 + .../gastown/drawer-panels/BeadPanel.tsx | 454 +++- src/lib/gastown/types/init.d.ts | 54 +- src/lib/gastown/types/router.d.ts | 2305 ++++++++++------- src/lib/gastown/types/schemas.d.ts | 1259 ++++----- 5 files changed, 2315 insertions(+), 1805 deletions(-) diff --git a/cloudflare-gastown/src/trpc/router.ts b/cloudflare-gastown/src/trpc/router.ts index c804c702b9..3990e9b43e 100644 --- a/cloudflare-gastown/src/trpc/router.ts +++ b/cloudflare-gastown/src/trpc/router.ts @@ -296,6 +296,54 @@ export const gastownRouter = router({ await townStub.deleteBead(input.beadId); }), + updateBead: gastownProcedure + .input( + z + .object({ + rigId: z.string().uuid(), + beadId: z.string().uuid(), + title: z.string().min(1).optional(), + body: z.string().nullable().optional(), + status: z.enum(['open', 'in_progress', 'in_review', 'closed', 'failed']).optional(), + priority: z.enum(['low', 'medium', 'high', 'critical']).optional(), + type: z + .enum([ + 'issue', + 'message', + 'escalation', + 'merge_request', + 'convoy', + 'molecule', + 'agent', + ]) + .optional(), + labels: z.array(z.string()).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + rig_id: z.string().min(1).nullable().optional(), + parent_bead_id: z.string().min(1).nullable().optional(), + }) + .refine( + data => + data.title !== undefined || + data.body !== undefined || + data.status !== undefined || + data.priority !== undefined || + data.type !== undefined || + data.labels !== undefined || + data.metadata !== undefined || + data.rig_id !== undefined || + data.parent_bead_id !== undefined, + { message: 'At least one field to update must be provided' } + ) + ) + .output(RpcBeadOutput) + .mutation(async ({ ctx, input }) => { + const rig = await verifyRigOwnership(ctx.env, ctx.userId, input.rigId); + const townStub = getTownDOStub(ctx.env, rig.town_id); + const { rigId: _rigId, beadId, ...fields } = input; + return townStub.updateBead(beadId, fields, ctx.userId); + }), + // ── Agents ────────────────────────────────────────────────────────── listAgents: gastownProcedure diff --git a/src/components/gastown/drawer-panels/BeadPanel.tsx b/src/components/gastown/drawer-panels/BeadPanel.tsx index 3b86c6a548..33afa0110d 100644 --- a/src/components/gastown/drawer-panels/BeadPanel.tsx +++ b/src/components/gastown/drawer-panels/BeadPanel.tsx @@ -1,8 +1,12 @@ 'use client'; -import { useQuery } from '@tanstack/react-query'; +import { useState, useCallback } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useGastownTRPC } from '@/lib/gastown/trpc'; import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; import { BeadEventTimeline, extractPrUrl } from '@/components/gastown/ActivityFeed'; import type { ResourceRef } from '@/components/gastown/DrawerStack'; @@ -25,13 +29,30 @@ import { GitPullRequest, CircleDot, Layers, + Pencil, + X, + Save, + Loader2, } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +const BEAD_STATUSES = ['open', 'in_progress', 'in_review', 'closed', 'failed'] as const; +const BEAD_PRIORITIES = ['low', 'medium', 'high', 'critical'] as const; +const BEAD_TYPES = [ + 'issue', + 'message', + 'escalation', + 'merge_request', + 'convoy', + 'molecule', + 'agent', +] as const; + const STATUS_STYLES: Record = { open: 'border-sky-500/30 bg-sky-500/10 text-sky-300', in_progress: 'border-amber-500/30 bg-amber-500/10 text-amber-300', + in_review: 'border-violet-500/30 bg-violet-500/10 text-violet-300', closed: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300', failed: 'border-red-500/30 bg-red-500/10 text-red-300', }; @@ -43,6 +64,18 @@ const PRIORITY_STYLES: Record = { low: 'text-white/50', }; +type EditState = { + title: string; + body: string; + status: string; + priority: string; + type: string; + labels: string; + metadata: string; + rig_id: string; + parent_bead_id: string; +}; + export function BeadPanel({ beadId, rigId, @@ -53,6 +86,7 @@ export function BeadPanel({ push: (ref: ResourceRef) => void; }) { const trpc = useGastownTRPC(); + const queryClient = useQueryClient(); const beadsQuery = useQuery(trpc.gastown.listBeads.queryOptions({ rigId })); const agentsQuery = useQuery(trpc.gastown.listAgents.queryOptions({ rigId })); const rigQuery = useQuery(trpc.gastown.getRig.queryOptions({ rigId })); @@ -70,6 +104,113 @@ export function BeadPanel({ return acc; }, {}); + // ── Edit mode state ─────────────────────────────────────────────────── + const [editing, setEditing] = useState(false); + const [editState, setEditState] = useState({ + title: '', + body: '', + status: '', + priority: '', + type: '', + labels: '', + metadata: '', + rig_id: '', + parent_bead_id: '', + }); + + const enterEditMode = useCallback(() => { + if (!bead) return; + setEditState({ + title: bead.title, + body: bead.body ?? '', + status: bead.status, + priority: bead.priority, + type: bead.type, + labels: bead.labels.join(', '), + metadata: + bead.metadata && Object.keys(bead.metadata).length > 0 + ? JSON.stringify(bead.metadata, null, 2) + : '', + rig_id: bead.rig_id ?? '', + parent_bead_id: bead.parent_bead_id ?? '', + }); + setEditing(true); + }, [bead]); + + const cancelEdit = useCallback(() => setEditing(false), []); + + const updateField = useCallback((field: K, value: EditState[K]) => { + setEditState(prev => ({ ...prev, [field]: value })); + }, []); + + const updateBeadMutation = useMutation( + trpc.gastown.updateBead.mutationOptions({ + onSuccess: () => { + setEditing(false); + // Invalidate bead-related queries to refresh data + queryClient.invalidateQueries({ queryKey: trpc.gastown.listBeads.queryKey({ rigId }) }); + queryClient.invalidateQueries({ queryKey: trpc.gastown.getRig.queryKey({ rigId }) }); + }, + }) + ); + + const handleSave = useCallback(() => { + if (!bead) return; + + // Build the diff — only send fields that changed + const updates: Record = {}; + if (editState.title !== bead.title) updates.title = editState.title; + if (editState.body !== (bead.body ?? '')) { + updates.body = editState.body || null; + } + if (editState.status !== bead.status) updates.status = editState.status; + if (editState.priority !== bead.priority) updates.priority = editState.priority; + if (editState.type !== bead.type) updates.type = editState.type; + + const newLabels = editState.labels + .split(',') + .map(l => l.trim()) + .filter(Boolean); + const oldLabels = bead.labels; + if (JSON.stringify(newLabels) !== JSON.stringify(oldLabels)) { + updates.labels = newLabels; + } + + if (editState.metadata.trim()) { + try { + const parsed = JSON.parse(editState.metadata) as Record; + if (JSON.stringify(parsed) !== JSON.stringify(bead.metadata)) { + updates.metadata = parsed; + } + } catch { + // Invalid JSON — skip metadata update + } + } else if (bead.metadata && Object.keys(bead.metadata).length > 0) { + updates.metadata = {}; + } + + const newRigId = editState.rig_id || null; + if (newRigId !== (bead.rig_id ?? null)) { + updates.rig_id = newRigId; + } + + const newParent = editState.parent_bead_id || null; + if (newParent !== (bead.parent_bead_id ?? null)) { + updates.parent_bead_id = newParent; + } + + if (Object.keys(updates).length === 0) { + setEditing(false); + return; + } + + updateBeadMutation.mutate({ + rigId, + beadId: bead.bead_id, + ...updates, + } as Parameters[0]); + }, [bead, editState, rigId, updateBeadMutation]); + if (!bead) { return
Loading bead…
; } @@ -98,91 +239,217 @@ export function BeadPanel({
{/* Title area */}
-
+
- {bead.title} -
-
- - {bead.status.replace('_', ' ')} - - - {bead.type} - - - - {bead.priority} - -
-
- - {/* Metadata grid */} -
- - - - {/* Assignee — clickable to open agent drawer */} - {bead.assignee_agent_bead_id ? ( + {editing ? ( + updateField('title', e.target.value)} + className="h-7 border-white/10 bg-white/5 text-sm font-semibold text-white/90" + /> + ) : ( + {bead.title} + )} +
+ + {editing ? ( +
+ updateField('status', v)} + label="Status" + /> + updateField('type', v)} + label="Type" + /> + updateField('priority', v)} + label="Priority" + /> +
) : ( - +
+ + {bead.status.replace('_', ' ')} + + + {bead.type} + + + + {bead.priority} + +
)} +
- - - {/* Parent bead — clickable */} - {bead.parent_bead_id && ( -
IDTitleTypeStatusPriorityAssignee
' + short(b.id) + '' + esc(b.title) + '' + b.type + '' + badge(b.status) + '' + (b.priority || 'medium') + '' + (b.assignee_agent_id ? short(b.assignee_agent_id) : '—') + '' + + ' ' + (b.status !== 'closed' ? '' : '') + '