Skip to content
58 changes: 58 additions & 0 deletions cloudflare-gastown/container/plugin/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,64 @@ export class MayorGastownClient {
async getConvoyStatus(convoyId: string): Promise<ConvoyDetail> {
return this.request<ConvoyDetail>(this.mayorPath(`/convoys/${convoyId}`));
}

async updateBead(
rigId: string,
beadId: string,
input: {
title?: string;
body?: string;
status?: 'open' | 'in_progress' | 'in_review' | 'closed' | 'failed';
priority?: 'low' | 'medium' | 'high' | 'critical';
labels?: string[];
}
): Promise<Bead> {
return this.request<Bead>(this.mayorPath(`/rigs/${rigId}/beads/${beadId}`), {
method: 'PATCH',
body: JSON.stringify(input),
});
}

async reassignBead(rigId: string, beadId: string, agentId: string): Promise<Bead> {
return this.request<Bead>(this.mayorPath(`/rigs/${rigId}/beads/${beadId}/reassign`), {
method: 'POST',
body: JSON.stringify({ agent_id: agentId }),
});
}

async deleteBead(rigId: string, beadId: string): Promise<void> {
await this.request<void>(this.mayorPath(`/rigs/${rigId}/beads/${beadId}`), {
method: 'DELETE',
});
}

async resetAgent(rigId: string, agentId: string): Promise<void> {
await this.request<void>(this.mayorPath(`/rigs/${rigId}/agents/${agentId}/reset`), {
method: 'POST',
});
}

async closeConvoy(convoyId: string): Promise<void> {
await this.request<void>(this.mayorPath(`/convoys/${convoyId}/close`), {
method: 'POST',
});
}

async updateConvoy(
convoyId: string,
input: { merge_mode?: 'review-then-land' | 'review-and-merge'; feature_branch?: string }
): Promise<void> {
await this.request<void>(this.mayorPath(`/convoys/${convoyId}`), {
method: 'PATCH',
body: JSON.stringify(input),
});
}

async acknowledgeEscalation(escalationId: string): Promise<void> {
await this.request<void>(this.mayorPath(`/escalations/${escalationId}/acknowledge`), {
method: 'POST',
});
}
}

export class GastownApiError extends Error {
Expand Down
94 changes: 94 additions & 0 deletions cloudflare-gastown/container/plugin/mayor-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ function makeFakeMayorClient(overrides: Partial<MayorGastownClient> = {}): Mayor
},
],
}),
updateBead: vi.fn<() => Promise<Bead>>().mockResolvedValue(FAKE_BEAD),
reassignBead: vi.fn<() => Promise<Bead>>().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;
}
Expand Down Expand Up @@ -245,4 +252,91 @@ 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');
});
});
});
113 changes: 113 additions & 0 deletions cloudflare-gastown/container/plugin/mayor-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', 'in_review', '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.`;
},
}),
};
}
1 change: 1 addition & 0 deletions cloudflare-gastown/src/db/tables/bead-events.table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const BeadEventType = z.enum([
'pr_creation_failed',
'agent_status',
'triage_resolved',
'fields_updated',
]);

export type BeadEventType = z.infer<typeof BeadEventType>;
Expand Down
Loading
Loading