diff --git a/services/gastown/container/plugin/client.ts b/services/gastown/container/plugin/client.ts index aae607bef0..e4a3be97ef 100644 --- a/services/gastown/container/plugin/client.ts +++ b/services/gastown/container/plugin/client.ts @@ -409,6 +409,7 @@ export class MayorGastownClient { status?: 'open' | 'in_progress' | 'in_review' | 'closed' | 'failed'; priority?: 'low' | 'medium' | 'high' | 'critical'; labels?: string[]; + depends_on?: string[]; } ): Promise { return this.request(this.mayorPath(`/rigs/${rigId}/beads/${beadId}`), { @@ -417,6 +418,27 @@ export class MayorGastownClient { }); } + async convoyAddBead( + convoyId: string, + beadId: string, + dependsOn?: string[] + ): Promise<{ total_beads: number }> { + return this.request<{ total_beads: number }>(this.mayorPath(`/convoys/${convoyId}/add-bead`), { + method: 'POST', + body: JSON.stringify({ bead_id: beadId, depends_on: dependsOn }), + }); + } + + async convoyRemoveBead(convoyId: string, beadId: string): Promise<{ total_beads: number }> { + return this.request<{ total_beads: number }>( + this.mayorPath(`/convoys/${convoyId}/remove-bead`), + { + method: 'POST', + body: JSON.stringify({ bead_id: beadId }), + } + ); + } + async reassignBead(rigId: string, beadId: string, agentId: string): Promise { return this.request(this.mayorPath(`/rigs/${rigId}/beads/${beadId}/reassign`), { method: 'POST', diff --git a/services/gastown/container/plugin/mayor-tools.ts b/services/gastown/container/plugin/mayor-tools.ts index bff2459451..85d08e94e1 100644 --- a/services/gastown/container/plugin/mayor-tools.ts +++ b/services/gastown/container/plugin/mayor-tools.ts @@ -292,7 +292,7 @@ export function createMayorTools(client: MayorGastownClient) { }), gt_bead_update: tool({ - description: "Edit a bead's status, title, body, priority, or labels.", + description: "Edit a bead's status, title, body, priority, labels, or dependency blockers.", 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'), @@ -310,6 +310,13 @@ export function createMayorTools(client: MayorGastownClient) { .array(tool.schema.string()) .describe('Replacement labels array for the bead') .optional(), + depends_on: tool.schema + .array(tool.schema.string()) + .describe( + "Replace this bead's blockers. Pass an array of bead UUIDs that must be closed before this bead can be dispatched. " + + 'Pass an empty array [] to remove all blockers. Omit to leave dependencies unchanged.' + ) + .optional(), }, async execute(args) { const bead = await client.updateBead(args.rig_id, args.bead_id, { @@ -318,6 +325,7 @@ export function createMayorTools(client: MayorGastownClient) { status: args.status, priority: args.priority, labels: args.labels, + depends_on: args.depends_on, }); return `Bead ${bead.bead_id} updated. Status: ${bead.status}, Priority: ${bead.priority}, Title: "${bead.title}".`; }, @@ -379,6 +387,40 @@ export function createMayorTools(client: MayorGastownClient) { }, }), + gt_convoy_add_bead: tool({ + description: + 'Add an existing bead to an existing convoy. Use this after gt_sling to make a standalone bead ' + + "part of a convoy's tracked progress and landing. The bead will count toward the convoy's " + + 'completion and will be included in the convoy landing.', + args: { + convoy_id: tool.schema.string().describe('UUID of the convoy to add the bead to'), + bead_id: tool.schema.string().describe('UUID of the bead to add'), + depends_on: tool.schema + .array(tool.schema.string()) + .describe('Optional: bead UUIDs that must complete before this bead is dispatched') + .optional(), + }, + async execute(args) { + const result = await client.convoyAddBead(args.convoy_id, args.bead_id, args.depends_on); + return `Bead ${args.bead_id} added to convoy ${args.convoy_id}. Convoy now tracking ${result.total_beads} beads.`; + }, + }), + + gt_convoy_remove_bead: tool({ + description: + 'Remove a bead from a convoy. The bead will no longer count toward convoy progress or landing. ' + + 'Dependency edges between this bead and other convoy beads are also removed. ' + + 'The bead itself is not deleted — it becomes a standalone bead.', + args: { + convoy_id: tool.schema.string().describe('UUID of the convoy'), + bead_id: tool.schema.string().describe('UUID of the bead to remove from the convoy'), + }, + async execute(args) { + const result = await client.convoyRemoveBead(args.convoy_id, args.bead_id); + return `Bead ${args.bead_id} removed from convoy ${args.convoy_id}. Convoy now tracking ${result.total_beads} beads.`; + }, + }), + gt_convoy_update: tool({ description: 'Edit convoy metadata (merge_mode, feature_branch).', args: { diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index 70d58dadb1..9171b826dd 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -1183,6 +1183,7 @@ export class TownDO extends DurableObject { labels: string[]; status: BeadStatus; metadata: Record; + depends_on: string[]; }>, actorId: string ): Promise { @@ -1195,7 +1196,12 @@ export class TownDO extends DurableObject { }); } - const bead = beadOps.updateBeadFields(this.sql, beadId, fields, actorId); + const { depends_on, ...beadFields } = fields; + const bead = beadOps.updateBeadFields(this.sql, beadId, beadFields, actorId); + + if (depends_on !== undefined) { + beadOps.setDependencies(this.sql, beadId, depends_on); + } // When a bead closes via field update, check for newly unblocked beads if (fields.status === 'closed' || fields.status === 'failed') { @@ -1205,6 +1211,67 @@ export class TownDO extends DurableObject { return bead; } + /** Add an existing bead to a convoy's tracking. Returns updated convoy metadata. */ + async convoyAddBead( + convoyId: string, + beadId: string, + dependsOn?: string[] + ): Promise<{ total_beads: number }> { + const convoyCheck = [ + ...query( + this.sql, + /* sql */ `SELECT 1 FROM ${convoy_metadata} WHERE ${convoy_metadata.bead_id} = ?`, + [convoyId] + ), + ]; + if (convoyCheck.length === 0) throw new Error(`Bead ${convoyId} is not a convoy`); + beadOps.convoyAddBead(this.sql, convoyId, beadId); + if (dependsOn !== undefined) { + beadOps.setDependencies(this.sql, beadId, dependsOn); + } + const rows = [ + ...query( + this.sql, + /* sql */ ` + SELECT ${convoy_metadata.total_beads} + FROM ${convoy_metadata} + WHERE ${convoy_metadata.bead_id} = ? + `, + [convoyId] + ), + ]; + const parsed = z.object({ total_beads: z.number() }).array().parse(rows); + const total = parsed[0]?.total_beads ?? 0; + return { total_beads: total }; + } + + /** Remove a bead from a convoy's tracking. Returns updated convoy metadata. */ + async convoyRemoveBead(convoyId: string, beadId: string): Promise<{ total_beads: number }> { + const convoyCheck = [ + ...query( + this.sql, + /* sql */ `SELECT 1 FROM ${convoy_metadata} WHERE ${convoy_metadata.bead_id} = ?`, + [convoyId] + ), + ]; + if (convoyCheck.length === 0) throw new Error(`Bead ${convoyId} is not a convoy`); + beadOps.convoyRemoveBead(this.sql, convoyId, beadId); + const rows = [ + ...query( + this.sql, + /* sql */ ` + SELECT ${convoy_metadata.total_beads} + FROM ${convoy_metadata} + WHERE ${convoy_metadata.bead_id} = ? + `, + [convoyId] + ), + ]; + const parsed = z.object({ total_beads: z.number() }).array().parse(rows); + const total = parsed[0]?.total_beads ?? 0; + return { total_beads: total }; + } + /** * 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. diff --git a/services/gastown/src/dos/town/beads.ts b/services/gastown/src/dos/town/beads.ts index b2a8355dd2..bb4bc73c15 100644 --- a/services/gastown/src/dos/town/beads.ts +++ b/services/gastown/src/dos/town/beads.ts @@ -530,6 +530,178 @@ export function insertDependency( ); } +/** + * Atomically replace all 'blocks' edges for a bead. + * Deletes existing blockers then inserts the provided list. + * Self-loops are silently skipped. + */ +export function setDependencies(sql: SqlStorage, beadId: string, dependsOnBeadIds: string[]): void { + query( + sql, + /* sql */ ` + DELETE FROM ${bead_dependencies} + WHERE ${bead_dependencies.bead_id} = ? + AND ${bead_dependencies.dependency_type} = 'blocks' + `, + [beadId] + ); + for (const depId of dependsOnBeadIds) { + if (depId === beadId) continue; // no self-loops + query( + sql, + /* sql */ ` + INSERT OR IGNORE INTO ${bead_dependencies} ( + ${bead_dependencies.columns.bead_id}, + ${bead_dependencies.columns.depends_on_bead_id}, + ${bead_dependencies.columns.dependency_type} + ) VALUES (?, ?, 'blocks') + `, + [beadId, depId] + ); + } +} + +/** + * Add a bead to a convoy's tracking. + * Inserts a 'tracks' edge and increments total_beads — but only when the + * edge is genuinely new (INSERT OR IGNORE returns 0 changes if it already + * exists, so we check before updating the counter). + * Returns whether the bead was newly added (true) or already tracked (false). + */ +export function convoyAddBead(sql: SqlStorage, convoyId: string, beadId: string): boolean { + // Check if already tracked + const existing = [ + ...query( + sql, + /* sql */ ` + SELECT 1 FROM ${bead_dependencies} + WHERE ${bead_dependencies.bead_id} = ? + AND ${bead_dependencies.depends_on_bead_id} = ? + AND ${bead_dependencies.dependency_type} = 'tracks' + `, + [beadId, convoyId] + ), + ]; + if (existing.length > 0) return false; + + query( + sql, + /* sql */ ` + INSERT INTO ${bead_dependencies} ( + ${bead_dependencies.columns.bead_id}, + ${bead_dependencies.columns.depends_on_bead_id}, + ${bead_dependencies.columns.dependency_type} + ) VALUES (?, ?, 'tracks') + `, + [beadId, convoyId] + ); + query( + sql, + /* sql */ ` + UPDATE ${convoy_metadata} + SET ${convoy_metadata.columns.total_beads} = ${convoy_metadata.columns.total_beads} + 1 + WHERE ${convoy_metadata.bead_id} = ? + `, + [convoyId] + ); + return true; +} + +/** + * Remove a bead from a convoy's tracking. + * Deletes the 'tracks' edge, decrements total_beads (floor 0), and removes + * any 'blocks' edges between this bead and other beads in the same convoy. + * Returns whether the bead was actually tracked (true) or not found (false). + */ +export function convoyRemoveBead(sql: SqlStorage, convoyId: string, beadId: string): boolean { + // Check if tracked + const existing = [ + ...query( + sql, + /* sql */ ` + SELECT 1 FROM ${bead_dependencies} + WHERE ${bead_dependencies.bead_id} = ? + AND ${bead_dependencies.depends_on_bead_id} = ? + AND ${bead_dependencies.dependency_type} = 'tracks' + `, + [beadId, convoyId] + ), + ]; + if (existing.length === 0) return false; + + // Remove the tracks edge + query( + sql, + /* sql */ ` + DELETE FROM ${bead_dependencies} + WHERE ${bead_dependencies.bead_id} = ? + AND ${bead_dependencies.depends_on_bead_id} = ? + AND ${bead_dependencies.dependency_type} = 'tracks' + `, + [beadId, convoyId] + ); + // Decrement total_beads, floor at 0 + query( + sql, + /* sql */ ` + UPDATE ${convoy_metadata} + SET ${convoy_metadata.columns.total_beads} = MAX(0, ${convoy_metadata.columns.total_beads} - 1) + WHERE ${convoy_metadata.bead_id} = ? + `, + [convoyId] + ); + + // Find all other beads tracked by this convoy + const siblingRows = [ + ...query( + sql, + /* sql */ ` + SELECT ${bead_dependencies.bead_id} + FROM ${bead_dependencies} + WHERE ${bead_dependencies.depends_on_bead_id} = ? + AND ${bead_dependencies.dependency_type} = 'tracks' + `, + [convoyId] + ), + ]; + const siblingIds = z + .object({ bead_id: z.string() }) + .array() + .parse(siblingRows) + .map(r => r.bead_id); + + if (siblingIds.length > 0) { + // Delete 'blocks' edges where beadId is the blocker of a sibling + for (const siblingId of siblingIds) { + query( + sql, + /* sql */ ` + DELETE FROM ${bead_dependencies} + WHERE ${bead_dependencies.bead_id} = ? + AND ${bead_dependencies.depends_on_bead_id} = ? + AND ${bead_dependencies.dependency_type} = 'blocks' + `, + [siblingId, beadId] + ); + } + // Delete 'blocks' edges where beadId depends on a sibling + for (const siblingId of siblingIds) { + query( + sql, + /* sql */ ` + DELETE FROM ${bead_dependencies} + WHERE ${bead_dependencies.bead_id} = ? + AND ${bead_dependencies.depends_on_bead_id} = ? + AND ${bead_dependencies.dependency_type} = 'blocks' + `, + [beadId, siblingId] + ); + } + } + + return true; +} + /** * Find beads that were blocked by `closedBeadId` and are now fully unblocked * (all their 'blocks' dependencies are resolved). diff --git a/services/gastown/src/gastown.worker.ts b/services/gastown/src/gastown.worker.ts index 1484534e0f..14546cf07f 100644 --- a/services/gastown/src/gastown.worker.ts +++ b/services/gastown/src/gastown.worker.ts @@ -119,6 +119,8 @@ import { handleMayorConvoyStart, handleMayorUiAction, handleMayorGetPendingNudges, + handleMayorConvoyAddBead, + handleMayorConvoyRemoveBead, } from './handlers/mayor-tools.handler'; import { mayorAuthMiddleware } from './middleware/mayor-auth.middleware'; import { townAuthMiddleware } from './middleware/town-auth.middleware'; @@ -1019,6 +1021,16 @@ app.post('/api/mayor/:townId/tools/escalations/:escalationId/acknowledge', c => app.post('/api/mayor/:townId/tools/convoys/:convoyId/start', c => handleMayorConvoyStart(c, c.req.param()) ); +app.post('/api/mayor/:townId/tools/convoys/:convoyId/add-bead', c => + instrumented(c, 'POST /api/mayor/:townId/tools/convoys/:convoyId/add-bead', () => + handleMayorConvoyAddBead(c, c.req.param()) + ) +); +app.post('/api/mayor/:townId/tools/convoys/:convoyId/remove-bead', c => + instrumented(c, 'POST /api/mayor/:townId/tools/convoys/:convoyId/remove-bead', () => + handleMayorConvoyRemoveBead(c, c.req.param()) + ) +); // ── tRPC ──────────────────────────────────────────────────────────────── // Serve the gastown tRPC router directly. The frontend tRPC client // connects here instead of going through the Next.js proxy layer. diff --git a/services/gastown/src/handlers/mayor-tools.handler.ts b/services/gastown/src/handlers/mayor-tools.handler.ts index ad55ba0200..00a4996a8f 100644 --- a/services/gastown/src/handlers/mayor-tools.handler.ts +++ b/services/gastown/src/handlers/mayor-tools.handler.ts @@ -392,6 +392,7 @@ const BeadUpdateBody = z 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(), + depends_on: z.array(z.string().uuid()).optional(), }) .refine( data => @@ -402,7 +403,8 @@ const BeadUpdateBody = z data.status !== undefined || data.metadata !== undefined || data.rig_id !== undefined || - data.parent_bead_id !== undefined, + data.parent_bead_id !== undefined || + data.depends_on !== undefined, { message: 'At least one field must be provided' } ); @@ -748,6 +750,69 @@ export async function handleMayorConvoyStart( return c.json(resSuccess(result)); } +const ConvoyAddBeadBody = z.object({ + bead_id: z.string().uuid(), + depends_on: z.array(z.string().uuid()).optional(), +}); + +/** + * POST /api/mayor/:townId/tools/convoys/:convoyId/add-bead + * Add an existing bead to a convoy's tracking. + */ +export async function handleMayorConvoyAddBead( + c: Context, + params: { townId: string; convoyId: string } +) { + const parsed = ConvoyAddBeadBody.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} handleMayorConvoyAddBead: townId=${params.townId} convoyId=${params.convoyId} beadId=${parsed.data.bead_id}` + ); + + const town = getTownDOStub(c.env, params.townId); + const result = await town.convoyAddBead( + params.convoyId, + parsed.data.bead_id, + parsed.data.depends_on + ); + return c.json(resSuccess(result)); +} + +const ConvoyRemoveBeadBody = z.object({ + bead_id: z.string().uuid(), +}); + +/** + * POST /api/mayor/:townId/tools/convoys/:convoyId/remove-bead + * Remove a bead from a convoy's tracking. + */ +export async function handleMayorConvoyRemoveBead( + c: Context, + params: { townId: string; convoyId: string } +) { + const parsed = ConvoyRemoveBeadBody.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} handleMayorConvoyRemoveBead: townId=${params.townId} convoyId=${params.convoyId} beadId=${parsed.data.bead_id}` + ); + + const town = getTownDOStub(c.env, params.townId); + const result = await town.convoyRemoveBead(params.convoyId, parsed.data.bead_id); + return c.json(resSuccess(result)); +} + const MayorUiActionBody = z.object({ action: UiActionSchema, }); diff --git a/services/gastown/src/trpc/router.ts b/services/gastown/src/trpc/router.ts index 603691b770..1b446d2790 100644 --- a/services/gastown/src/trpc/router.ts +++ b/services/gastown/src/trpc/router.ts @@ -682,6 +682,7 @@ export const gastownRouter = router({ 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(), + depends_on: z.array(z.string().uuid()).optional(), }) .refine( data => @@ -692,7 +693,8 @@ export const gastownRouter = router({ data.labels !== undefined || data.metadata !== undefined || data.rig_id !== undefined || - data.parent_bead_id !== undefined, + data.parent_bead_id !== undefined || + data.depends_on !== undefined, { message: 'At least one field to update must be provided' } ) ) @@ -714,6 +716,37 @@ export const gastownRouter = router({ return townStub.updateBead(beadId, fields, ctx.userId); }), + convoyAddBead: gastownProcedure + .input( + z.object({ + townId: z.string().uuid(), + convoyId: z.string().uuid(), + beadId: z.string().uuid(), + depends_on: z.array(z.string().uuid()).optional(), + }) + ) + .output(z.object({ total_beads: z.number() })) + .mutation(async ({ ctx, input }) => { + await verifyTownOwnership(ctx.env, ctx, input.townId); + const townStub = getTownDOStub(ctx.env, input.townId); + return townStub.convoyAddBead(input.convoyId, input.beadId, input.depends_on); + }), + + convoyRemoveBead: gastownProcedure + .input( + z.object({ + townId: z.string().uuid(), + convoyId: z.string().uuid(), + beadId: z.string().uuid(), + }) + ) + .output(z.object({ total_beads: z.number() })) + .mutation(async ({ ctx, input }) => { + await verifyTownOwnership(ctx.env, ctx, input.townId); + const townStub = getTownDOStub(ctx.env, input.townId); + return townStub.convoyRemoveBead(input.convoyId, input.beadId); + }), + deleteBeadsByStatus: gastownProcedure .input( z.object({