Skip to content

Commit dc756e5

Browse files
authored
feat(gastown): add mayor edit tools and manual editing UI (#1076)
* 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 * 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. * 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. * fix(gastown): move Save button to top of bead edit form The Save button was at the bottom of the edit fields section, making it hard to find when only changing status/type/priority. Moved it inline with the status/type/priority selects so it's always visible at the top of the edit form. Removed the duplicate Save/Cancel bar from the bottom. * fix(gastown): address PR review feedback — security, lint, and formatting - Remove bead type from editable fields to prevent inconsistent state (type changes require satellite metadata tables that aren't created) - Add rig ownership check in handleMayorBeadUpdate matching the pattern in handleMayorBeadDelete — rejects updates to beads in other rigs - Fix reassign atomicity: hook new agent before unhooking old one so failures don't leave beads unassigned - Fix reassign unhook safety: verify old agent is still hooked to this specific bead before unhooking (stale assignee_agent_bead_id) - Fix reassign response: return updated bead instead of { reassigned } to match client contract - Fix ESLint: void floating promises, destructure useMutation result - Fix Prettier formatting in 5 files - Regenerate router.d.ts type declarations * fix(gastown): enforce rig scoping on reassign, reset, and tRPC updateBead - Reassign: validate target agent belongs to the specified rig and verify bead belongs to the rig before allowing reassignment - Agent reset: verify agent belongs to the specified rig before allowing reset (prevents cross-rig reset via any valid rig ID) - tRPC updateBead: verify bead belongs to the specified rig before allowing mutation (mirrors the HTTP handler fix) * fix(gastown): address second round of PR review feedback - Fix pre-existing type errors: use sql.exec() directly for dynamic SET clause queries where query() can't statically verify param count - Clear closed_at when reopening a previously-closed bead (status transition away from 'closed' now nulls out the stale timestamp) - Add in_review to gt_bead_update tool status enum and client type - Remove Type dropdown from debug dashboard (API no longer accepts it) * fix(gastown): address third round of PR review feedback - Dashboard unhook: use mayorApi() with mayor route instead of api() with the rig route, so the bearer token is sent correctly - Dashboard bead table: use b.bead_id (the actual PK) instead of b.id (undefined), and b.assignee_agent_bead_id for the assignee column - BeadPanel metadata: reject save with inline error when metadata JSON is invalid instead of silently dropping the field. Error clears on edit and displays with red border on the textarea.
1 parent 82b7c49 commit dc756e5

15 files changed

Lines changed: 2193 additions & 102 deletions

File tree

cloudflare-gastown/container/plugin/client.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,64 @@ export class MayorGastownClient {
348348
async getConvoyStatus(convoyId: string): Promise<ConvoyDetail> {
349349
return this.request<ConvoyDetail>(this.mayorPath(`/convoys/${convoyId}`));
350350
}
351+
352+
async updateBead(
353+
rigId: string,
354+
beadId: string,
355+
input: {
356+
title?: string;
357+
body?: string;
358+
status?: 'open' | 'in_progress' | 'in_review' | 'closed' | 'failed';
359+
priority?: 'low' | 'medium' | 'high' | 'critical';
360+
labels?: string[];
361+
}
362+
): Promise<Bead> {
363+
return this.request<Bead>(this.mayorPath(`/rigs/${rigId}/beads/${beadId}`), {
364+
method: 'PATCH',
365+
body: JSON.stringify(input),
366+
});
367+
}
368+
369+
async reassignBead(rigId: string, beadId: string, agentId: string): Promise<Bead> {
370+
return this.request<Bead>(this.mayorPath(`/rigs/${rigId}/beads/${beadId}/reassign`), {
371+
method: 'POST',
372+
body: JSON.stringify({ agent_id: agentId }),
373+
});
374+
}
375+
376+
async deleteBead(rigId: string, beadId: string): Promise<void> {
377+
await this.request<void>(this.mayorPath(`/rigs/${rigId}/beads/${beadId}`), {
378+
method: 'DELETE',
379+
});
380+
}
381+
382+
async resetAgent(rigId: string, agentId: string): Promise<void> {
383+
await this.request<void>(this.mayorPath(`/rigs/${rigId}/agents/${agentId}/reset`), {
384+
method: 'POST',
385+
});
386+
}
387+
388+
async closeConvoy(convoyId: string): Promise<void> {
389+
await this.request<void>(this.mayorPath(`/convoys/${convoyId}/close`), {
390+
method: 'POST',
391+
});
392+
}
393+
394+
async updateConvoy(
395+
convoyId: string,
396+
input: { merge_mode?: 'review-then-land' | 'review-and-merge'; feature_branch?: string }
397+
): Promise<void> {
398+
await this.request<void>(this.mayorPath(`/convoys/${convoyId}`), {
399+
method: 'PATCH',
400+
body: JSON.stringify(input),
401+
});
402+
}
403+
404+
async acknowledgeEscalation(escalationId: string): Promise<void> {
405+
await this.request<void>(this.mayorPath(`/escalations/${escalationId}/acknowledge`), {
406+
method: 'POST',
407+
});
408+
}
351409
}
352410

353411
export class GastownApiError extends Error {

cloudflare-gastown/container/plugin/mayor-tools.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,13 @@ function makeFakeMayorClient(overrides: Partial<MayorGastownClient> = {}): Mayor
121121
},
122122
],
123123
}),
124+
updateBead: vi.fn<() => Promise<Bead>>().mockResolvedValue(FAKE_BEAD),
125+
reassignBead: vi.fn<() => Promise<Bead>>().mockResolvedValue(FAKE_BEAD),
126+
deleteBead: vi.fn().mockResolvedValue(undefined),
127+
resetAgent: vi.fn().mockResolvedValue(undefined),
128+
closeConvoy: vi.fn().mockResolvedValue(undefined),
129+
updateConvoy: vi.fn().mockResolvedValue(undefined),
130+
acknowledgeEscalation: vi.fn().mockResolvedValue(undefined),
124131
...overrides,
125132
} as unknown as MayorGastownClient;
126133
}
@@ -245,4 +252,91 @@ describe('mayor tools', () => {
245252
expect(result).toContain('No rigs configured');
246253
});
247254
});
255+
256+
describe('gt_bead_update', () => {
257+
it('updates bead fields and returns summary', async () => {
258+
const result = await tools.gt_bead_update.execute(
259+
{ rig_id: 'rig-1', bead_id: 'bead-1', status: 'closed', priority: 'high' },
260+
CTX
261+
);
262+
expect(result).toContain('bead-1');
263+
expect(result).toContain('updated');
264+
expect(client.updateBead).toHaveBeenCalledWith('rig-1', 'bead-1', {
265+
title: undefined,
266+
body: undefined,
267+
status: 'closed',
268+
priority: 'high',
269+
labels: undefined,
270+
});
271+
});
272+
});
273+
274+
describe('gt_bead_reassign', () => {
275+
it('reassigns bead to new agent', async () => {
276+
const result = await tools.gt_bead_reassign.execute(
277+
{ rig_id: 'rig-1', bead_id: 'bead-1', agent_id: 'agent-2' },
278+
CTX
279+
);
280+
expect(result).toContain('bead-1');
281+
expect(result).toContain('agent-2');
282+
expect(client.reassignBead).toHaveBeenCalledWith('rig-1', 'bead-1', 'agent-2');
283+
});
284+
});
285+
286+
describe('gt_bead_delete', () => {
287+
it('deletes bead and confirms', async () => {
288+
const result = await tools.gt_bead_delete.execute(
289+
{ rig_id: 'rig-1', bead_id: 'bead-1' },
290+
CTX
291+
);
292+
expect(result).toContain('bead-1');
293+
expect(result).toContain('deleted');
294+
expect(client.deleteBead).toHaveBeenCalledWith('rig-1', 'bead-1');
295+
});
296+
});
297+
298+
describe('gt_agent_reset', () => {
299+
it('resets agent to idle', async () => {
300+
const result = await tools.gt_agent_reset.execute(
301+
{ rig_id: 'rig-1', agent_id: 'agent-1' },
302+
CTX
303+
);
304+
expect(result).toContain('agent-1');
305+
expect(result).toContain('idle');
306+
expect(client.resetAgent).toHaveBeenCalledWith('rig-1', 'agent-1');
307+
});
308+
});
309+
310+
describe('gt_convoy_close', () => {
311+
it('force-closes a convoy', async () => {
312+
const result = await tools.gt_convoy_close.execute({ convoy_id: 'convoy-1' }, CTX);
313+
expect(result).toContain('convoy-1');
314+
expect(result).toContain('closed');
315+
expect(client.closeConvoy).toHaveBeenCalledWith('convoy-1');
316+
});
317+
});
318+
319+
describe('gt_convoy_update', () => {
320+
it('updates convoy metadata', async () => {
321+
const result = await tools.gt_convoy_update.execute(
322+
{ convoy_id: 'convoy-1', merge_mode: 'review-and-merge' },
323+
CTX
324+
);
325+
expect(result).toContain('convoy-1');
326+
expect(result).toContain('updated');
327+
expect(client.updateConvoy).toHaveBeenCalledWith('convoy-1', {
328+
merge_mode: 'review-and-merge',
329+
feature_branch: undefined,
330+
});
331+
});
332+
});
333+
334+
describe('gt_escalation_acknowledge', () => {
335+
it('acknowledges an escalation', async () => {
336+
const result = await tools.gt_escalation_acknowledge.execute({ escalation_id: 'esc-1' }, CTX);
337+
expect(result).toContain('esc-1');
338+
expect(result).toContain('acknowledged');
339+
expect(client.acknowledgeEscalation).toHaveBeenCalledWith('esc-1');
340+
});
341+
});
248342
});

cloudflare-gastown/container/plugin/mayor-tools.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,5 +244,118 @@ export function createMayorTools(client: MayorGastownClient) {
244244
return `Mail sent to agent ${args.to_agent_id} in rig ${args.rig_id}.`;
245245
},
246246
}),
247+
248+
gt_bead_update: tool({
249+
description: "Edit a bead's status, title, body, priority, or labels.",
250+
args: {
251+
rig_id: tool.schema.string().describe('The UUID of the rig the bead belongs to'),
252+
bead_id: tool.schema.string().describe('The UUID of the bead to update'),
253+
title: tool.schema.string().describe('New title for the bead').optional(),
254+
body: tool.schema.string().describe('New body/description for the bead').optional(),
255+
status: tool.schema
256+
.enum(['open', 'in_progress', 'in_review', 'closed', 'failed'])
257+
.describe('New status for the bead')
258+
.optional(),
259+
priority: tool.schema
260+
.enum(['low', 'medium', 'high', 'critical'])
261+
.describe('New priority for the bead')
262+
.optional(),
263+
labels: tool.schema
264+
.array(tool.schema.string())
265+
.describe('Replacement labels array for the bead')
266+
.optional(),
267+
},
268+
async execute(args) {
269+
const bead = await client.updateBead(args.rig_id, args.bead_id, {
270+
title: args.title,
271+
body: args.body,
272+
status: args.status,
273+
priority: args.priority,
274+
labels: args.labels,
275+
});
276+
return `Bead ${bead.bead_id} updated. Status: ${bead.status}, Priority: ${bead.priority}, Title: "${bead.title}".`;
277+
},
278+
}),
279+
280+
gt_bead_reassign: tool({
281+
description: 'Reassign a bead to a different agent.',
282+
args: {
283+
rig_id: tool.schema.string().describe('The UUID of the rig the bead belongs to'),
284+
bead_id: tool.schema.string().describe('The UUID of the bead to reassign'),
285+
agent_id: tool.schema.string().describe('The UUID of the agent to assign the bead to'),
286+
},
287+
async execute(args) {
288+
const bead = await client.reassignBead(args.rig_id, args.bead_id, args.agent_id);
289+
return `Bead ${bead.bead_id} reassigned to agent ${args.agent_id}.`;
290+
},
291+
}),
292+
293+
gt_bead_delete: tool({
294+
description: 'Delete a bead. Use with caution — this is irreversible.',
295+
args: {
296+
rig_id: tool.schema.string().describe('The UUID of the rig the bead belongs to'),
297+
bead_id: tool.schema.string().describe('The UUID of the bead to delete'),
298+
},
299+
async execute(args) {
300+
await client.deleteBead(args.rig_id, args.bead_id);
301+
return `Bead ${args.bead_id} deleted.`;
302+
},
303+
}),
304+
305+
gt_agent_reset: tool({
306+
description: 'Force-reset an agent to idle, unhooking from any bead.',
307+
args: {
308+
rig_id: tool.schema.string().describe('The UUID of the rig the agent belongs to'),
309+
agent_id: tool.schema.string().describe('The UUID of the agent to reset'),
310+
},
311+
async execute(args) {
312+
await client.resetAgent(args.rig_id, args.agent_id);
313+
return `Agent ${args.agent_id} reset to idle.`;
314+
},
315+
}),
316+
317+
gt_convoy_close: tool({
318+
description: 'Force-close a convoy and optionally its tracked beads.',
319+
args: {
320+
convoy_id: tool.schema.string().describe('The UUID of the convoy to force-close'),
321+
},
322+
async execute(args) {
323+
await client.closeConvoy(args.convoy_id);
324+
return `Convoy ${args.convoy_id} force-closed.`;
325+
},
326+
}),
327+
328+
gt_convoy_update: tool({
329+
description: 'Edit convoy metadata (merge_mode, feature_branch).',
330+
args: {
331+
convoy_id: tool.schema.string().describe('The UUID of the convoy to update'),
332+
merge_mode: tool.schema
333+
.enum(['review-then-land', 'review-and-merge'])
334+
.describe('New merge mode for the convoy')
335+
.optional(),
336+
feature_branch: tool.schema
337+
.string()
338+
.describe('New feature branch name for the convoy')
339+
.optional(),
340+
},
341+
async execute(args) {
342+
await client.updateConvoy(args.convoy_id, {
343+
merge_mode: args.merge_mode,
344+
feature_branch: args.feature_branch,
345+
});
346+
return `Convoy ${args.convoy_id} updated.`;
347+
},
348+
}),
349+
350+
gt_escalation_acknowledge: tool({
351+
description: 'Acknowledge an escalation.',
352+
args: {
353+
escalation_id: tool.schema.string().describe('The UUID of the escalation to acknowledge'),
354+
},
355+
async execute(args) {
356+
await client.acknowledgeEscalation(args.escalation_id);
357+
return `Escalation ${args.escalation_id} acknowledged.`;
358+
},
359+
}),
247360
};
248361
}

cloudflare-gastown/src/db/tables/bead-events.table.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const BeadEventType = z.enum([
1919
'pr_creation_failed',
2020
'agent_status',
2121
'triage_resolved',
22+
'fields_updated',
2223
]);
2324

2425
export type BeadEventType = z.infer<typeof BeadEventType>;

0 commit comments

Comments
 (0)