diff --git a/cloudflare-gastown/container/plugin/client.ts b/cloudflare-gastown/container/plugin/client.ts index 02710ae382..7023a7edca 100644 --- a/cloudflare-gastown/container/plugin/client.ts +++ b/cloudflare-gastown/container/plugin/client.ts @@ -7,6 +7,7 @@ import type { BeadType, Convoy, ConvoyDetail, + ConvoyStartResult, GastownEnv, Mail, MayorGastownEnv, @@ -334,6 +335,7 @@ export class MayorGastownClient { tasks: Array<{ title: string; body?: string; depends_on?: number[] }>; merge_mode?: 'review-then-land' | 'review-and-merge'; parallel?: boolean; + staged?: boolean; }): Promise { return this.request(this.mayorPath('/sling-batch'), { method: 'POST', @@ -341,6 +343,13 @@ export class MayorGastownClient { }); } + async startConvoy(convoyId: string): Promise { + return this.request(this.mayorPath(`/convoys/${convoyId}/start`), { + method: 'POST', + body: JSON.stringify({}), + }); + } + async listConvoys(): Promise { return this.request(this.mayorPath('/convoys')); } diff --git a/cloudflare-gastown/container/plugin/mayor-tools.test.ts b/cloudflare-gastown/container/plugin/mayor-tools.test.ts index aa6daba7c7..fe21e25673 100644 --- a/cloudflare-gastown/container/plugin/mayor-tools.test.ts +++ b/cloudflare-gastown/container/plugin/mayor-tools.test.ts @@ -5,6 +5,7 @@ import type { Bead, Convoy, ConvoyDetail, + ConvoyStartResult, Rig, SlingBatchResult, SlingResult, @@ -60,6 +61,7 @@ const FAKE_CONVOY: Convoy = { id: 'convoy-1', title: 'JWT Authentication', status: 'active', + staged: false, total_beads: 3, closed_beads: 1, created_by: null, @@ -67,6 +69,18 @@ const FAKE_CONVOY: Convoy = { landed_at: null, }; +const FAKE_STAGED_CONVOY: Convoy = { + id: 'convoy-staged-1', + title: 'Big Refactor', + status: 'active', + staged: true, + total_beads: 2, + closed_beads: 0, + created_by: null, + created_at: '2026-03-05T00:00:00Z', + landed_at: null, +}; + function makeFakeMayorClient(overrides: Partial = {}): MayorGastownClient { return { sling: vi.fn<() => Promise>().mockResolvedValue({ @@ -95,8 +109,22 @@ function makeFakeMayorClient(overrides: Partial = {}): Mayor ], }), listConvoys: vi.fn<() => Promise>().mockResolvedValue([FAKE_CONVOY]), + startConvoy: vi.fn<() => Promise>().mockResolvedValue({ + convoy: { ...FAKE_STAGED_CONVOY, status: 'active', staged: false }, + beads: [ + { + bead: { ...FAKE_BEAD, bead_id: 'bead-1', title: 'Task 1' }, + agent: { ...FAKE_AGENT, id: 'agent-1', name: 'Toast' }, + }, + { + bead: { ...FAKE_BEAD, bead_id: 'bead-2', title: 'Task 2' }, + agent: { ...FAKE_AGENT, id: 'agent-2', name: 'Muffin' }, + }, + ], + }), getConvoyStatus: vi.fn<() => Promise>().mockResolvedValue({ ...FAKE_CONVOY, + staged: false, beads: [ { bead_id: 'bead-1', @@ -339,4 +367,46 @@ describe('mayor tools', () => { expect(client.acknowledgeEscalation).toHaveBeenCalledWith('esc-1'); }); }); + + describe('gt_sling_batch staged', () => { + it('passes staged=true to client and reports convoy as staged', async () => { + client = makeFakeMayorClient({ + slingBatch: vi.fn<() => Promise>().mockResolvedValue({ + convoy: FAKE_STAGED_CONVOY, + beads: [ + { + bead: { ...FAKE_BEAD, bead_id: 'bead-1', title: 'Task 1' }, + agent: null, + }, + { + bead: { ...FAKE_BEAD, bead_id: 'bead-2', title: 'Task 2' }, + agent: null, + }, + ], + }), + }); + tools = createMayorTools(client); + + const tasks = [{ title: 'Task 1' }, { title: 'Task 2', depends_on: [0] }]; + const result = await tools.gt_sling_batch.execute( + { rig_id: 'rig-1', convoy_title: 'Big Refactor', tasks, staged: true }, + CTX + ); + + expect(result).toContain('Convoy staged:'); + expect(result).toContain('convoy-staged-1'); + expect(result).toContain('gt_convoy_start'); + expect(result).toContain('unassigned, pending gt_convoy_start'); + expect(client.slingBatch).toHaveBeenCalledWith(expect.objectContaining({ staged: true })); + }); + }); + + describe('gt_convoy_start', () => { + it('starts a staged convoy and reports bead count', async () => { + const result = await tools.gt_convoy_start.execute({ convoy_id: 'convoy-staged-1' }, CTX); + expect(result).toContain('Convoy started'); + expect(result).toContain('2 bead(s)'); + expect(client.startConvoy).toHaveBeenCalledWith('convoy-staged-1'); + }); + }); }); diff --git a/cloudflare-gastown/container/plugin/mayor-tools.ts b/cloudflare-gastown/container/plugin/mayor-tools.ts index 7d6ecb04e6..92d29482fe 100644 --- a/cloudflare-gastown/container/plugin/mayor-tools.ts +++ b/cloudflare-gastown/container/plugin/mayor-tools.ts @@ -169,6 +169,14 @@ export function createMayorTools(client: MayorGastownClient) { 'that need ordering, which causes merge conflicts and failures.' ) .optional(), + staged: tool.schema + .boolean() + .describe( + 'If true, creates the convoy plan without dispatching agents. ' + + 'The user can review and edit before calling gt_convoy_start to begin execution. ' + + 'Default: false (dispatch immediately).' + ) + .optional(), }, async execute(args) { const result = await client.slingBatch({ @@ -177,21 +185,30 @@ export function createMayorTools(client: MayorGastownClient) { tasks: args.tasks, merge_mode: args.merge_mode, parallel: args.parallel, + staged: args.staged, }); const beadLines = result.beads.map( - (b: { bead: { title: string }; agent: { name: string; id: string } }, i: number) => - ` ${i + 1}. "${b.bead.title}" → ${b.agent.name} (${b.agent.id})` + ( + b: { bead: { title: string }; agent: { name: string; id: string } | null }, + i: number + ) => + b.agent + ? ` ${i + 1}. "${b.bead.title}" → ${b.agent.name} (${b.agent.id})` + : ` ${i + 1}. "${b.bead.title}" (unassigned, pending gt_convoy_start)` ); const mode = args.merge_mode ?? 'review-then-land'; + const staged = result.convoy.staged; return [ - `Convoy created: "${result.convoy.title}" (${result.convoy.id})`, + `Convoy ${staged ? 'staged' : 'created'}: "${result.convoy.title}" (${result.convoy.id})`, `Merge mode: ${mode}`, `Tracking ${result.convoy.total_beads} beads:`, ...beadLines, - mode === 'review-then-land' - ? `Beads will be reviewed and merged into the convoy feature branch. A final PR/merge to main occurs when all beads are done.` - : `Each bead will go through the full review + merge/PR cycle independently.`, + staged + ? `Convoy is staged — agents have NOT been dispatched. Call gt_convoy_start with convoy_id "${result.convoy.id}" when ready to begin execution.` + : mode === 'review-then-land' + ? `Beads will be reviewed and merged into the convoy feature branch. A final PR/merge to main occurs when all beads are done.` + : `Each bead will go through the full review + merge/PR cycle independently.`, ].join('\n'); }, }), @@ -223,6 +240,21 @@ export function createMayorTools(client: MayorGastownClient) { }, }), + gt_convoy_start: tool({ + description: + 'Start a staged convoy. Transitions the convoy from staged (planned but not executing) ' + + 'to active: hooks agents to all tracked beads and begins dispatch. ' + + 'Call this when the user approves a staged plan and says to start it.', + args: { + convoy_id: tool.schema.string().describe('The UUID of the staged convoy to start'), + }, + async execute(args) { + const result = await client.startConvoy(args.convoy_id); + const beadCount = result.beads?.length ?? 0; + return `Convoy started. ${beadCount} bead(s) dispatched to agents.`; + }, + }), + gt_mail_send: tool({ description: 'Send a mail message to an agent in any rig. ' + diff --git a/cloudflare-gastown/container/plugin/types.ts b/cloudflare-gastown/container/plugin/types.ts index 0fb1998ec3..1bc956b5d8 100644 --- a/cloudflare-gastown/container/plugin/types.ts +++ b/cloudflare-gastown/container/plugin/types.ts @@ -88,16 +88,20 @@ export type SlingResult = { }; // Sling batch result (convoy + beads + agents) +// agent is null for staged convoys (agents aren't assigned until gt_convoy_start) export type SlingBatchResult = { convoy: Convoy; - beads: Array<{ bead: Bead; agent: Agent }>; + beads: Array<{ bead: Bead; agent: Agent | null }>; }; // Convoy summary (returned by list and status endpoints) +// Staging is tracked by the `staged` boolean, not the status field. +// status tracks the convoy lifecycle: active (in progress) or landed (complete). export type Convoy = { id: string; title: string; status: 'active' | 'landed'; + staged: boolean; total_beads: number; closed_beads: number; created_by: string | null; @@ -105,6 +109,12 @@ export type Convoy = { landed_at: string | null; }; +// Result returned by POST /convoys/:id/start +export type ConvoyStartResult = { + convoy: Convoy; + beads: Array<{ bead: Bead; agent: Agent }>; +}; + // Detailed convoy status with per-bead breakdown export type ConvoyDetail = Convoy & { beads: Array<{ diff --git a/cloudflare-gastown/src/db/tables/convoy-metadata.table.ts b/cloudflare-gastown/src/db/tables/convoy-metadata.table.ts index 5ef4d8ea11..063bd1fe60 100644 --- a/cloudflare-gastown/src/db/tables/convoy-metadata.table.ts +++ b/cloudflare-gastown/src/db/tables/convoy-metadata.table.ts @@ -19,6 +19,8 @@ export const ConvoyMetadataRecord = z.object({ * individually, like standalone beads. */ merge_mode: ConvoyMergeMode.nullable(), + /** 1 = staged (planned, agents not dispatched), 0 = active (SQLite boolean) */ + staged: z.number().int().default(0), }); export type ConvoyMetadataRecord = z.output; @@ -32,7 +34,8 @@ export function createTableConvoyMetadata(): string { closed_beads: `integer not null default 0`, landed_at: `text`, feature_branch: `text`, - merge_mode: `text`, + merge_mode: `text check(merge_mode in ('review-then-land', 'review-and-merge'))`, + staged: `integer not null default 0`, }); } @@ -40,6 +43,7 @@ export function createTableConvoyMetadata(): string { export function migrateConvoyMetadata(): string[] { return [ `ALTER TABLE convoy_metadata ADD COLUMN feature_branch text`, - `ALTER TABLE convoy_metadata ADD COLUMN merge_mode text`, + `ALTER TABLE convoy_metadata ADD COLUMN merge_mode text check(merge_mode in ('review-then-land', 'review-and-merge'))`, + `ALTER TABLE convoy_metadata ADD COLUMN staged integer not null default 0`, ]; } diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index 2fabe00121..877f3234f9 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -171,6 +171,7 @@ type ConvoyEntry = { id: string; title: string; status: 'active' | 'landed'; + staged: boolean; total_beads: number; closed_beads: number; created_by: string | null; @@ -185,6 +186,7 @@ function toConvoy(row: ConvoyBeadRecord): ConvoyEntry { id: row.bead_id, title: row.title, status: row.status === 'closed' ? 'landed' : 'active', + staged: row.staged === 1, total_beads: row.total_beads, closed_beads: row.closed_beads, created_by: row.created_by, @@ -199,7 +201,7 @@ const CONVOY_JOIN = /* sql */ ` SELECT ${beads}.*, ${convoy_metadata.total_beads}, ${convoy_metadata.closed_beads}, ${convoy_metadata.landed_at}, ${convoy_metadata.feature_branch}, - ${convoy_metadata.merge_mode} + ${convoy_metadata.merge_mode}, ${convoy_metadata.staged} FROM ${beads} INNER JOIN ${convoy_metadata} ON ${beads.bead_id} = ${convoy_metadata.bead_id} `; @@ -1849,9 +1851,14 @@ export class TownDO extends DurableObject { convoyTitle: string; tasks: Array<{ title: string; body?: string; depends_on?: number[] }>; merge_mode?: 'review-then-land' | 'review-and-merge'; - }): Promise<{ convoy: ConvoyEntry; beads: Array<{ bead: Bead; agent: Agent }> }> { + staged?: boolean; + }): Promise<{ convoy: ConvoyEntry; beads: Array<{ bead: Bead; agent: Agent | null }> }> { await this.ensureInitialized(); + // Resolve staged: explicit request wins, otherwise fall back to town config default. + const townConfig = await this.getTownConfig(); + const isStaged = input.staged ?? townConfig.staged_convoys_default; + const convoyId = generateId(); const timestamp = now(); @@ -1941,21 +1948,24 @@ export class TownDO extends DurableObject { const mergeMode = input.merge_mode ?? 'review-then-land'; + const stagedValue = isStaged ? 1 : 0; + query( this.sql, /* sql */ ` INSERT INTO ${convoy_metadata} ( ${convoy_metadata.columns.bead_id}, ${convoy_metadata.columns.total_beads}, ${convoy_metadata.columns.closed_beads}, ${convoy_metadata.columns.landed_at}, - ${convoy_metadata.columns.feature_branch}, ${convoy_metadata.columns.merge_mode} - ) VALUES (?, ?, ?, ?, ?, ?) + ${convoy_metadata.columns.feature_branch}, ${convoy_metadata.columns.merge_mode}, + ${convoy_metadata.columns.staged} + ) VALUES (?, ?, ?, ?, ?, ?, ?) `, - [convoyId, input.tasks.length, 0, null, featureBranch, mergeMode] + [convoyId, input.tasks.length, 0, null, featureBranch, mergeMode, stagedValue] ); // 2. Create all beads and track their IDs (needed for depends_on resolution) const beadIds: string[] = []; - const results: Array<{ bead: Bead; agent: Agent }> = []; + const results: Array<{ bead: Bead; agent: Agent | null }> = []; for (const task of input.tasks) { const createdBead = beadOps.createBead(this.sql, { @@ -2002,40 +2012,147 @@ export class TownDO extends DurableObject { } } - // 4. For each bead: assign a polecat, but only dispatch if unblocked - for (let i = 0; i < beadIds.length; i++) { - const beadId = beadIds[i]; - const agent = agents.getOrCreateAgent(this.sql, 'polecat', input.rigId, this.townId); - agents.hookBead(this.sql, agent.id, beadId); + if (isStaged) { + // Staged mode: collect beads without hooking agents or dispatching. + for (const beadId of beadIds) { + const bead = beadOps.getBead(this.sql, beadId); + if (!bead) continue; + results.push({ bead, agent: null }); + } + } else { + // 4. For each bead: assign a polecat, but only dispatch if unblocked + for (let i = 0; i < beadIds.length; i++) { + const beadId = beadIds[i]; + const agent = agents.getOrCreateAgent(this.sql, 'polecat', input.rigId, this.townId); + agents.hookBead(this.sql, agent.id, beadId); + + const bead = beadOps.getBead(this.sql, beadId); + const hookedAgent = agents.getAgent(this.sql, agent.id) ?? agent; + if (!bead) continue; + + // Only dispatch beads with no unresolved blockers + if (!beadOps.hasUnresolvedBlockers(this.sql, beadId)) { + this.dispatchAgent(hookedAgent, bead).catch(err => + console.error(`${TOWN_LOG} slingConvoy: fire-and-forget dispatchAgent failed:`, err) + ); + } else { + console.log( + `${TOWN_LOG} slingConvoy: bead=${beadId} blocked, deferring dispatch until deps close` + ); + } + + results.push({ bead, agent: hookedAgent }); + } + await this.armAlarmIfNeeded(); + } + + const convoy = this.getConvoy(convoyId); + if (!convoy) throw new Error('Failed to create convoy'); + this.emitEvent({ + event: 'convoy.created', + townId: this.townId, + convoyId, + }); + return { convoy, beads: results }; + } + + /** + * Transition a staged convoy to active: hook agents and begin dispatch. + */ + async startConvoy( + convoyId: string + ): Promise<{ convoy: ConvoyEntry; beads: Array<{ bead: Bead; agent: Agent }> }> { + await this.ensureInitialized(); + + const convoy = this.getConvoy(convoyId); + if (!convoy) throw new Error(`Convoy not found: ${convoyId}`); + if (!convoy.staged) throw new Error(`Convoy is not staged: ${convoyId}`); + + // Find all beads tracked by this convoy + const trackedRows = [ + ...query( + this.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 BeadIdRow = z.object({ bead_id: z.string() }); + const trackedBeadIds = BeadIdRow.array() + .parse(trackedRows) + .map(r => r.bead_id); + + const results: Array<{ bead: Bead; agent: Agent }> = []; + + for (const beadId of trackedBeadIds) { const bead = beadOps.getBead(this.sql, beadId); - const hookedAgent = agents.getAgent(this.sql, agent.id) ?? agent; if (!bead) continue; - // Only dispatch beads with no unresolved blockers + const rigId = bead.rig_id; + if (!rigId) continue; + + // Skip beads already hooked from a prior partial attempt (retry-safe). + let hookedAgent: Agent; + if (bead.assignee_agent_bead_id) { + const existing = agents.getAgent(this.sql, bead.assignee_agent_bead_id); + if (existing) { + hookedAgent = existing; + } else { + // Orphaned assignee reference — re-hook with a fresh agent + const agent = agents.getOrCreateAgent(this.sql, 'polecat', rigId, this.townId); + agents.hookBead(this.sql, agent.id, beadId); + hookedAgent = agents.getAgent(this.sql, agent.id) ?? agent; + } + } else { + const agent = agents.getOrCreateAgent(this.sql, 'polecat', rigId, this.townId); + agents.hookBead(this.sql, agent.id, beadId); + hookedAgent = agents.getAgent(this.sql, agent.id) ?? agent; + } + + // Re-read bead after potential hookBead so assignee_agent_bead_id is up to date + const updatedBead = beadOps.getBead(this.sql, beadId) ?? bead; + if (!beadOps.hasUnresolvedBlockers(this.sql, beadId)) { - this.dispatchAgent(hookedAgent, bead).catch(err => - console.error(`${TOWN_LOG} slingConvoy: fire-and-forget dispatchAgent failed:`, err) + this.dispatchAgent(hookedAgent, updatedBead).catch(err => + console.error(`${TOWN_LOG} startConvoy: fire-and-forget dispatchAgent failed:`, err) ); } else { console.log( - `${TOWN_LOG} slingConvoy: bead=${beadId} blocked, deferring dispatch until deps close` + `${TOWN_LOG} startConvoy: bead=${beadId} blocked, deferring dispatch until deps close` ); } - results.push({ bead, agent: hookedAgent }); + results.push({ bead: updatedBead, agent: hookedAgent }); } + // Clear the staged flag only after all agents are successfully hooked. + // If the loop above throws, the convoy stays staged so the caller can retry. + query( + this.sql, + /* sql */ ` + UPDATE ${convoy_metadata} + SET ${convoy_metadata.columns.staged} = 0 + WHERE ${convoy_metadata.bead_id} = ? + `, + [convoyId] + ); + await this.armAlarmIfNeeded(); - const convoy = this.getConvoy(convoyId); - if (!convoy) throw new Error('Failed to create convoy'); + const updatedConvoy = this.getConvoy(convoyId); + if (!updatedConvoy) throw new Error(`Failed to re-fetch convoy after start: ${convoyId}`); this.emitEvent({ - event: 'convoy.created', + event: 'convoy.started', townId: this.townId, convoyId, }); - return { convoy, beads: results }; + return { convoy: updatedConvoy, beads: results }; } /** diff --git a/cloudflare-gastown/src/dos/town/patrol.ts b/cloudflare-gastown/src/dos/town/patrol.ts index b7e6298a4a..f434a9a815 100644 --- a/cloudflare-gastown/src/dos/town/patrol.ts +++ b/cloudflare-gastown/src/dos/town/patrol.ts @@ -12,6 +12,7 @@ import { z } from 'zod'; import { beads, BeadRecord as BeadRecordSchema } from '../../db/tables/beads.table'; import { agent_metadata, AgentMetadataRecord } from '../../db/tables/agent-metadata.table'; import { bead_dependencies } from '../../db/tables/bead-dependencies.table'; +import { convoy_metadata } from '../../db/tables/convoy-metadata.table'; import { query } from '../../util/query.util'; import { sendMail } from './mail'; import { deleteAgent, getOrCreateAgent, hookBead, unhookBead } from './agents'; @@ -501,8 +502,10 @@ export function detectStaleHooks(sql: SqlStorage): void { } /** - * Feed stranded convoys: find active convoys that have open beads with - * no assigned agent. Auto-sling by assigning idle polecats. + * Feed stranded convoys: find active (non-staged) convoys that have open + * beads with no assigned agent. Auto-sling by assigning idle polecats. + * Staged convoys are excluded — their beads remain unassigned until + * the convoy is explicitly started via startConvoy(). */ export function feedStrandedConvoys(sql: SqlStorage, townId: string): void { // Find open issue beads that: @@ -524,9 +527,11 @@ export function feedStrandedConvoys(sql: SqlStorage, townId: string): void { FROM ${bead_dependencies} INNER JOIN ${beads} ON ${bead_dependencies.bead_id} = ${beads.bead_id} INNER JOIN ${beads} AS convoy ON ${bead_dependencies.depends_on_bead_id} = convoy.${beads.columns.bead_id} + INNER JOIN ${convoy_metadata} ON ${convoy_metadata.bead_id} = convoy.${beads.columns.bead_id} WHERE ${bead_dependencies.dependency_type} = 'tracks' AND convoy.${beads.columns.type} = 'convoy' AND convoy.${beads.columns.status} = 'open' + AND ${convoy_metadata.staged} = 0 AND ${beads.status} = 'open' AND ${beads.type} = 'issue' AND ${beads.assignee_agent_bead_id} IS NULL diff --git a/cloudflare-gastown/src/gastown.worker.ts b/cloudflare-gastown/src/gastown.worker.ts index 09c72f98ec..343aaea4f2 100644 --- a/cloudflare-gastown/src/gastown.worker.ts +++ b/cloudflare-gastown/src/gastown.worker.ts @@ -95,6 +95,7 @@ import { handleMayorConvoyUpdate, handleMayorBeadDelete, handleMayorEscalationAcknowledge, + handleMayorConvoyStart, } from './handlers/mayor-tools.handler'; import { mayorAuthMiddleware } from './middleware/mayor-auth.middleware'; import { timingMiddleware, instrumented } from './middleware/analytics.middleware'; @@ -656,6 +657,9 @@ app.post('/api/mayor/:townId/tools/escalations/:escalationId/acknowledge', c => handleMayorEscalationAcknowledge(c, c.req.param()) ) ); +app.post('/api/mayor/:townId/tools/convoys/:convoyId/start', c => + handleMayorConvoyStart(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 208cc80482..bbeb882d5d 100644 --- a/cloudflare-gastown/src/handlers/mayor-tools.handler.ts +++ b/cloudflare-gastown/src/handlers/mayor-tools.handler.ts @@ -35,6 +35,7 @@ const MayorSlingBatchBody = z merge_mode: z.enum(['review-then-land', 'review-and-merge']).optional(), /** Set to true only when ALL tasks are genuinely independent (no shared files, no shared state). */ parallel: z.boolean().optional(), + staged: z.boolean().optional(), }) .superRefine((data, ctx) => { // Require dependency graph unless explicitly opted out with parallel: true. @@ -288,6 +289,7 @@ export async function handleMayorSlingBatch(c: Context, params: { to convoyTitle: parsed.data.convoy_title, tasks: parsed.data.tasks, merge_mode: parsed.data.merge_mode, + staged: parsed.data.staged, }); console.log( @@ -602,3 +604,34 @@ export async function handleMayorEscalationAcknowledge( if (!escalation) return c.json(resError('Escalation not found'), 404); return c.json(resSuccess(escalation)); } + +/** + * POST /api/mayor/:townId/tools/convoys/:convoyId/start + * Transition a staged convoy to active: hook agents and begin dispatch. + */ +export async function handleMayorConvoyStart( + c: Context, + params: { townId: string; convoyId: string } +) { + console.log( + `${HANDLER_LOG} handleMayorConvoyStart: townId=${params.townId} convoyId=${params.convoyId}` + ); + + const town = getTownDOStub(c.env, params.townId); + let result: { convoy: { id: string }; beads: unknown[] }; + try { + result = await town.startConvoy(params.convoyId); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes('not found') || message.includes('not staged')) { + return c.json(resError('Convoy not found or not staged'), 404); + } + throw err; + } + + console.log( + `${HANDLER_LOG} handleMayorConvoyStart: completed, convoy=${result.convoy.id} beads=${result.beads.length}` + ); + + return c.json(resSuccess(result)); +} diff --git a/cloudflare-gastown/src/prompts/mayor-system.prompt.ts b/cloudflare-gastown/src/prompts/mayor-system.prompt.ts index 9def1750ed..714278c4ad 100644 --- a/cloudflare-gastown/src/prompts/mayor-system.prompt.ts +++ b/cloudflare-gastown/src/prompts/mayor-system.prompt.ts @@ -26,9 +26,11 @@ Your #1 purpose is to turn user requests into actionable work items. Every time You have these tools for cross-rig coordination: - **gt_sling** — Delegate a single task to a polecat in a specific rig. Use for one-off tasks. -- **gt_sling_batch** — YOUR MOST IMPORTANT TOOL. Sling multiple beads as a tracked convoy. Use this when breaking work into parallel sub-tasks. Creates all beads at once, groups them into a convoy for progress tracking, and dispatches polecats automatically. Accepts an optional \`merge_mode\`: + - **gt_sling_batch** — YOUR MOST IMPORTANT TOOL. Sling multiple beads as a tracked convoy. Use this when breaking work into parallel sub-tasks. Creates all beads at once, groups them into a convoy for progress tracking, and dispatches polecats automatically. Accepts an optional \`merge_mode\`: - **"review-then-land"** (default): Each bead is reviewed by the refinery and merged into the convoy's feature branch. Only at the very end does a PR or merge to main occur. Best for tightly coupled tasks that build on each other. - **"review-and-merge"**: Each bead goes through the full review + merge/PR cycle independently. Best for loosely coupled tasks where each can land on its own. + - Accepts an optional \`staged\` boolean. When \`staged: true\`, creates the convoy and beads without dispatching agents — a plan for review. See the Staged Convoys section below. +- **gt_convoy_start** — Start a staged convoy. Transitions it from staged (planned, no agents) to active: hooks agents to all tracked beads and begins dispatch. Call this when the user approves a staged plan. - **gt_list_rigs** — List all rigs in your town. Returns rig ID, name, git URL, and default branch. Call this first when you need to know what repositories are available. - **gt_list_beads** — List beads (work items) in a rig. Filter by status or type. Use this to check progress, find open work, or review completed tasks. - **gt_list_agents** — List agents in a rig. Shows who is working, idle, or stuck. Use this to understand workforce capacity. @@ -221,6 +223,24 @@ Use these tools when the user reports stuck state, when you detect problems duri - You maintain context across messages. This is a continuous conversation. - Never fabricate rig IDs or agent IDs. Always use gt_list_rigs to discover real IDs. - If no rigs exist, tell the user they need to create one first. -- If a task spans multiple rigs, create separate slings for each rig. -- ALWAYS sling when the user requests work. Describing what you would do without actually slinging is a failure mode. Prefer gt_sling_batch for multi-task requests.`; + - If a task spans multiple rigs, create separate slings for each rig. + - ALWAYS sling when the user requests work. Describing what you would do without actually slinging is a failure mode. Prefer gt_sling_batch for multi-task requests. + +## Staged Convoys + +When the user asks you to plan or prepare work (but not start it immediately), +use gt_sling_batch with staged=true. This creates the convoy and beads without +dispatching agents — it's a plan for review. + +After creating a staged convoy, tell the user: +- The convoy title and number of beads +- A brief description of the plan +- That they can review the beads and say "start it" or "go" when ready + +When the user says "go", "start it", "kick it off", or similar for a staged +convoy, call gt_convoy_start with the convoy_id. + +For large convoys (>5 beads) where the decomposition is non-obvious, consider +using staged=true by default to give the user a chance to review before agents +start spending compute.`; } diff --git a/cloudflare-gastown/src/trpc/router.ts b/cloudflare-gastown/src/trpc/router.ts index 2a0dbc954b..2f9df5046a 100644 --- a/cloudflare-gastown/src/trpc/router.ts +++ b/cloudflare-gastown/src/trpc/router.ts @@ -628,6 +628,20 @@ export const gastownRouter = router({ return townStub.listConvoysDetailed(); }), + getConvoy: gastownProcedure + .input( + z.object({ + townId: z.string().uuid(), + convoyId: z.string().uuid(), + }) + ) + .output(RpcConvoyDetailOutput.nullable()) + .query(async ({ ctx, input }) => { + await verifyTownOwnership(ctx.env, ctx.userId, input.townId); + const townStub = getTownDOStub(ctx.env, input.townId); + return townStub.getConvoyStatus(input.convoyId); + }), + closeConvoy: gastownProcedure .input( z.object({ @@ -645,6 +659,22 @@ export const gastownRouter = router({ return status ?? { ...convoy, beads: [] }; }), + startConvoy: gastownProcedure + .input( + z.object({ + townId: z.string().uuid(), + convoyId: z.string().uuid(), + }) + ) + .output(RpcConvoyDetailOutput.nullable()) + .mutation(async ({ ctx, input }) => { + await verifyTownOwnership(ctx.env, ctx.userId, input.townId); + const townStub = getTownDOStub(ctx.env, input.townId); + await townStub.startConvoy(input.convoyId); + const status = await townStub.getConvoyStatus(input.convoyId); + return status ?? null; + }), + // ── Admin-only routes (bypass ownership checks) ────────────────────── adminListBeads: adminProcedure diff --git a/cloudflare-gastown/src/trpc/schemas.ts b/cloudflare-gastown/src/trpc/schemas.ts index 584d54817d..a71c07ffe5 100644 --- a/cloudflare-gastown/src/trpc/schemas.ts +++ b/cloudflare-gastown/src/trpc/schemas.ts @@ -118,6 +118,7 @@ export const ConvoyOutput = z.object({ id: z.string(), title: z.string(), status: z.enum(['active', 'landed']), + staged: z.boolean(), total_beads: z.number(), closed_beads: z.number(), created_by: z.string().nullable(), diff --git a/cloudflare-gastown/src/types.ts b/cloudflare-gastown/src/types.ts index f721a9463b..870f6ac02b 100644 --- a/cloudflare-gastown/src/types.ts +++ b/cloudflare-gastown/src/types.ts @@ -245,6 +245,9 @@ export const TownConfigSchema = z.object({ sleep_after_minutes: z.number().int().min(5).max(120).optional(), }) .optional(), + + /** When true, all convoys are created as staged by default (agents not dispatched until started). */ + staged_convoys_default: z.boolean().default(false), }); export type TownConfig = z.infer; @@ -285,6 +288,7 @@ export const TownConfigUpdateSchema = z.object({ sleep_after_minutes: z.number().int().min(5).max(120).optional(), }) .optional(), + staged_convoys_default: z.boolean().optional(), }); export type TownConfigUpdate = z.infer; diff --git a/cloudflare-gastown/src/ui/dashboard.ui.ts b/cloudflare-gastown/src/ui/dashboard.ui.ts index b45f5c432c..adc783ce90 100644 --- a/cloudflare-gastown/src/ui/dashboard.ui.ts +++ b/cloudflare-gastown/src/ui/dashboard.ui.ts @@ -58,6 +58,7 @@ export function dashboardHtml(): string { 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; } + .badge.staged { background: #21262d; color: #8b949e; border: 1px dashed #30363d; } .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; @@ -363,6 +364,19 @@ export function dashboardHtml(): string {
+ +
+

Convoys

+
+ + + + + +
+
+
+

API Log

@@ -1058,6 +1072,85 @@ async function containerSendMessage() { if (r.ok) { el('cMessage').value = ''; toast('Message sent'); } } +// ── Convoys ────────────────────────────────────────────────────────── + +async function loadConvoys() { + const convoyTownId = el('convoyTownId').value.trim(); + if (!convoyTownId) { toast('Set a Town ID first', true); return; } + const token = el('convoyMayorToken').value.trim(); + const log = el('apiLog'); + const path = '/api/mayor/' + convoyTownId + '/tools/convoys'; + log.innerHTML += 'GET ' + esc(path) + '\\n'; + try { + const res = await fetch(path, { + headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, + }); + 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; } + renderConvoys(data.data || [], convoyTownId); + } catch (e) { + log.innerHTML += 'FETCH ERROR: ' + esc(e.message) + '\\n\\n'; + toast(e.message, true); + } +} + +function renderConvoys(convoys, convoyTownId) { + if (!convoys.length) { el('convoysList').innerHTML = '

No convoys

'; return; } + let h = ''; + for (const c of convoys) { + const isStaged = c.staged === true; + const statusBadge = isStaged + ? 'STAGED' + : '' + (c.status || 'open') + ''; + const progress = (c.closed_beads != null && c.total_beads != null) + ? c.closed_beads + '/' + c.total_beads + : '—'; + h += '' + + '' + + '' + + '' + + '' + + '' + + ''; + } + h += '
IDTitleStatusBeads
' + short(c.id) + '' + esc(c.title || '—') + '' + statusBadge + '' + progress + '' + + (isStaged ? '' : '') + + '
'; + el('convoysList').innerHTML = h; +} + +async function startConvoy(convoyId, convoyTownId) { + const token = el('convoyMayorToken').value.trim(); + const log = el('apiLog'); + const path = '/api/mayor/' + convoyTownId + '/tools/convoys/' + convoyId + '/start'; + log.innerHTML += 'POST ' + esc(path) + '\\n'; + try { + const res = await fetch(path, { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, + body: '{}', + }); + 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 (data.success) { + toast('Convoy ' + convoyId.slice(0, 8) + ' started'); + loadConvoys(); + } else { + toast('Error: ' + (data.error || 'unknown'), true); + } + } catch (e) { + log.innerHTML += 'FETCH ERROR: ' + esc(e.message) + '\\n\\n'; + toast(e.message, true); + } +} + // ── Helpers ───────────────────────────────────────────────────────── function copyId(id) { diff --git a/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx b/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx index 1699e351e4..40c5fcfdcf 100644 --- a/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx +++ b/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx @@ -108,6 +108,17 @@ export function TownOverviewPageClient({ townId }: TownOverviewPageClientProps) onError: err => toast.error(err.message), }) ); + const startConvoyMutation = useMutation( + trpc.gastown.startConvoy.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: trpc.gastown.listConvoys.queryKey({ townId }), + }); + toast.success('Convoy started'); + }, + onError: err => toast.error(err.message), + }) + ); const rigs = rigsQuery.data ?? []; const events = townEventsQuery.data ?? []; @@ -335,7 +346,9 @@ export function TownOverviewPageClient({ townId }: TownOverviewPageClientProps) onSelectBead={(beadId, rigId) => { if (rigId) openDrawer({ type: 'bead', beadId, rigId }); }} + onSelectConvoy={convoyId => openDrawer({ type: 'convoy', convoyId, townId })} onCloseConvoy={convoyId => closeConvoyMutation.mutate({ townId, convoyId })} + onStartConvoy={convoyId => startConvoyMutation.mutate({ townId, convoyId })} />
diff --git a/src/app/(app)/gastown/[townId]/rigs/[rigId]/RigDetailPageClient.tsx b/src/app/(app)/gastown/[townId]/rigs/[rigId]/RigDetailPageClient.tsx index fd9b81e282..649e36418d 100644 --- a/src/app/(app)/gastown/[townId]/rigs/[rigId]/RigDetailPageClient.tsx +++ b/src/app/(app)/gastown/[townId]/rigs/[rigId]/RigDetailPageClient.tsx @@ -77,6 +77,17 @@ export function RigDetailPageClient({ townId, rigId }: RigDetailPageClientProps) onError: err => toast.error(err.message), }) ); + const startConvoyMutation = useMutation( + trpc.gastown.startConvoy.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: trpc.gastown.listConvoys.queryKey({ townId }), + }); + toast.success('Convoy started'); + }, + onError: err => toast.error(err.message), + }) + ); const beads = beadsQuery.data ?? []; const agents = agentsQuery.data ?? []; @@ -160,7 +171,9 @@ export function RigDetailPageClient({ townId, rigId }: RigDetailPageClientProps) onSelectBead={(beadId, beadRigId) => openDrawer({ type: 'bead', beadId, rigId: beadRigId ?? rigId }) } + onSelectConvoy={convoyId => openDrawer({ type: 'convoy', convoyId, townId })} onCloseConvoy={convoyId => closeConvoyMutation.mutate({ townId, convoyId })} + onStartConvoy={convoyId => startConvoyMutation.mutate({ townId, convoyId })} /> diff --git a/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx b/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx index c48b7d0596..9d563b28d9 100644 --- a/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx +++ b/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx @@ -21,6 +21,7 @@ import { Bot, Shield, Variable, + Layers, } from 'lucide-react'; import { motion } from 'motion/react'; @@ -33,6 +34,7 @@ const SECTIONS = [ { id: 'git-auth', label: 'Git Authentication', icon: GitBranch }, { id: 'env-vars', label: 'Environment Variables', icon: Variable }, { id: 'agent-defaults', label: 'Agent Defaults', icon: Bot }, + { id: 'convoys', label: 'Convoys', icon: Layers }, { id: 'merge-strategy', label: 'Merge Strategy', icon: GitPullRequest }, { id: 'refinery', label: 'Refinery', icon: Shield }, ] as const; @@ -101,6 +103,7 @@ export function TownSettingsPageClient({ townId }: Props) { const [refineryGates, setRefineryGates] = useState([]); const [autoMerge, setAutoMerge] = useState(true); const [mergeStrategy, setMergeStrategy] = useState<'direct' | 'pr'>('direct'); + const [stagedConvoysDefault, setStagedConvoysDefault] = useState(false); const [initialized, setInitialized] = useState(false); const [showTokens, setShowTokens] = useState(false); @@ -116,6 +119,7 @@ export function TownSettingsPageClient({ townId }: Props) { setRefineryGates(cfg.refinery?.gates ?? []); setAutoMerge(cfg.refinery?.auto_merge ?? true); setMergeStrategy(cfg.merge_strategy === 'pr' ? 'pr' : 'direct'); + setStagedConvoysDefault(cfg.staged_convoys_default ?? false); setInitialized(true); } @@ -141,6 +145,7 @@ export function TownSettingsPageClient({ townId }: Props) { ...(defaultModel ? { default_model: defaultModel } : {}), ...(maxPolecats ? { max_polecats_per_rig: maxPolecats } : {}), merge_strategy: mergeStrategy, + staged_convoys_default: stagedConvoysDefault, refinery: { gates: refineryGates.filter(g => g.trim()), auto_merge: autoMerge, @@ -360,13 +365,37 @@ export function TownSettingsPageClient({ townId }: Props) { + {/* ── Convoys ──────────────────────────────────────── */} + +
+ +
+ +

+ When enabled, new convoys are created in staged mode — agents are not + dispatched until the convoy is explicitly started. This gives the mayor a + chance to review and adjust the plan before execution begins. +

+
+
+
+ {/* ── Merge Strategy ──────────────────────────────────── */}
void; + onSelectConvoy?: (convoyId: string) => void; onCloseConvoy?: (convoyId: string) => void; + onStartConvoy?: (convoyId: string) => void; }; const STATUS_COLORS: Record = { @@ -105,7 +107,9 @@ export function ConvoyTimeline({ convoys, collapsed = false, onSelectBead, + onSelectConvoy, onCloseConvoy, + onStartConvoy, }: ConvoyTimelineProps) { if (convoys.length === 0) { return null; @@ -125,7 +129,9 @@ export function ConvoyTimeline({ key={convoy.id} convoy={convoy} onSelectBead={onSelectBead} + onSelectConvoy={onSelectConvoy ? () => onSelectConvoy(convoy.id) : undefined} onClose={onCloseConvoy ? () => onCloseConvoy(convoy.id) : undefined} + onStart={convoy.staged && onStartConvoy ? () => onStartConvoy(convoy.id) : undefined} /> ))} @@ -137,13 +143,18 @@ export function ConvoyTimeline({ function ConvoyCard({ convoy, onSelectBead, + onSelectConvoy, onClose, + onStart, }: { convoy: ConvoyDetail; onSelectBead?: (beadId: string, rigId: string | null) => void; + onSelectConvoy?: () => void; onClose?: () => void; + onStart?: () => void; }) { const [confirming, setConfirming] = useState(false); + const isStaged = 'staged' in convoy && convoy.staged === true; const progress = convoy.total_beads > 0 ? convoy.closed_beads / convoy.total_beads : 0; const waves = useMemo( @@ -154,19 +165,24 @@ function ConvoyCard({ const hasDag = (convoy.dependency_edges ?? []).length > 0; return ( -
+
{/* Convoy header */}
- - CONVOY - + {isStaged ? 'STAGED' : 'CONVOY'} + + {convoy.feature_branch && (
+ {onStart && ( + + )} {convoy.closed_beads}/{convoy.total_beads} diff --git a/src/components/gastown/DrawerStack.tsx b/src/components/gastown/DrawerStack.tsx index e84776804e..e97ddbf120 100644 --- a/src/components/gastown/DrawerStack.tsx +++ b/src/components/gastown/DrawerStack.tsx @@ -10,7 +10,8 @@ import type { TownEvent } from './ActivityFeed'; export type ResourceRef = | { type: 'bead'; beadId: string; rigId: string } | { type: 'agent'; agentId: string; rigId: string; townId?: string } - | { type: 'event'; event: TownEvent }; + | { type: 'event'; event: TownEvent } + | { type: 'convoy'; convoyId: string; townId: string }; type DrawerStackEntry = { key: string; diff --git a/src/components/gastown/DrawerStackContent.tsx b/src/components/gastown/DrawerStackContent.tsx index 876b6f0fd8..5e21f3b70d 100644 --- a/src/components/gastown/DrawerStackContent.tsx +++ b/src/components/gastown/DrawerStackContent.tsx @@ -5,6 +5,7 @@ import type { ResourceRef } from './DrawerStack'; import { BeadPanel } from './drawer-panels/BeadPanel'; import { AgentPanel } from './drawer-panels/AgentPanel'; import { EventPanel } from './drawer-panels/EventPanel'; +import { ConvoyPanel } from './drawer-panels/ConvoyPanel'; /** * Dispatch function that maps a ResourceRef to the right panel component. @@ -29,5 +30,9 @@ export function renderDrawerContent( ); case 'event': return ; + case 'convoy': + return ( + + ); } } diff --git a/src/components/gastown/drawer-panels/ConvoyPanel.tsx b/src/components/gastown/drawer-panels/ConvoyPanel.tsx new file mode 100644 index 0000000000..4c2cc7342a --- /dev/null +++ b/src/components/gastown/drawer-panels/ConvoyPanel.tsx @@ -0,0 +1,359 @@ +'use client'; + +import { useMemo } 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 type { ResourceRef } from '@/components/gastown/DrawerStack'; + +import { format } from 'date-fns'; +import { + Layers, + GitBranch, + Clock, + Play, + CheckCircle, + Loader2, + ArrowRight, + Hexagon, + Hash, +} from 'lucide-react'; +import { toast } from 'sonner'; + +type ConvoyBead = { + bead_id: string; + title: string; + status: string; + rig_id: string | null; + assignee_agent_name: string | null; +}; + +type DependencyEdge = { + bead_id: string; + depends_on_bead_id: string; +}; + +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', + closed: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300', + failed: 'border-red-500/30 bg-red-500/10 text-red-300', +}; + +const STATUS_DOT_COLORS: Record = { + open: 'bg-sky-400', + in_progress: 'bg-amber-400', + closed: 'bg-emerald-400', + failed: 'bg-red-400', +}; + +/** + * Topological wave grouping via Kahn's algorithm. + * Wave 0 = no incoming edges, Wave N = all blockers in prior waves. + */ +function computeWaves(beadList: ConvoyBead[], edges: DependencyEdge[]): ConvoyBead[][] { + const beadIds = new Set(beadList.map(b => b.bead_id)); + const inDegree = new Map(); + const blockedBy = new Map(); + for (const b of beadList) { + inDegree.set(b.bead_id, 0); + blockedBy.set(b.bead_id, []); + } + for (const edge of edges) { + if (!beadIds.has(edge.bead_id) || !beadIds.has(edge.depends_on_bead_id)) continue; + inDegree.set(edge.bead_id, (inDegree.get(edge.bead_id) ?? 0) + 1); + blockedBy.get(edge.bead_id)?.push(edge.depends_on_bead_id); + } + + const beadById = new Map(beadList.map(b => [b.bead_id, b])); + const waves: ConvoyBead[][] = []; + const remaining = new Set(beadIds); + + while (remaining.size > 0) { + const wave: ConvoyBead[] = []; + for (const id of remaining) { + if ((inDegree.get(id) ?? 0) === 0) { + const bead = beadById.get(id); + if (bead) wave.push(bead); + } + } + if (wave.length === 0) { + const rest: ConvoyBead[] = []; + for (const id of remaining) { + const bead = beadById.get(id); + if (bead) rest.push(bead); + } + waves.push(rest); + break; + } + waves.push(wave); + for (const bead of wave) remaining.delete(bead.bead_id); + for (const id of remaining) { + const blockers = blockedBy.get(id) ?? []; + let newDeg = 0; + for (const dep of blockers) { + if (remaining.has(dep)) newDeg++; + } + inDegree.set(id, newDeg); + } + } + return waves; +} + +export function ConvoyPanel({ + convoyId, + townId, + push, +}: { + convoyId: string; + townId: string; + push: (ref: ResourceRef) => void; +}) { + const trpc = useGastownTRPC(); + const queryClient = useQueryClient(); + + const convoyQuery = useQuery({ + ...trpc.gastown.getConvoy.queryOptions({ townId, convoyId }), + refetchInterval: 5_000, + }); + + const convoy = convoyQuery.data ?? null; + + const startConvoyMutation = useMutation( + trpc.gastown.startConvoy.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: trpc.gastown.getConvoy.queryKey({ townId, convoyId }), + }); + void queryClient.invalidateQueries({ + queryKey: trpc.gastown.listConvoys.queryKey({ townId }), + }); + toast.success('Convoy started'); + }, + onError: err => toast.error(err.message), + }) + ); + + const waves = useMemo( + () => computeWaves(convoy?.beads ?? [], convoy?.dependency_edges ?? []), + [convoy?.beads, convoy?.dependency_edges] + ); + + if (convoyQuery.isLoading) { + return ( +
+ +
+ ); + } + + if (!convoy) { + return
Convoy not found
; + } + + const progress = convoy.total_beads > 0 ? convoy.closed_beads / convoy.total_beads : 0; + const hasDag = (convoy.dependency_edges ?? []).length > 0; + + return ( +
+ {/* Header */} +
+
+ + Convoy + {convoy.staged && ( + + STAGED + + )} + {!convoy.staged && convoy.status === 'active' && ( + + ACTIVE + + )} + {convoy.status === 'landed' && ( + + LANDED + + )} +
+

{convoy.title}

+
+ + {/* Staged banner + start button */} + {convoy.staged && ( +
+
+ This convoy is staged — agents have not been dispatched yet. Start the convoy when + you're ready to begin execution. +
+ +
+ )} + + {/* Progress */} +
+
+ Progress + + {convoy.closed_beads}/{convoy.total_beads} + +
+
+
+
+
+ + {/* Metadata grid */} +
+ } + label="ID" + value={convoy.id.slice(0, 8)} + mono + /> + } + label="Created" + value={format(new Date(convoy.created_at), 'MMM d, HH:mm')} + /> + {convoy.feature_branch && ( + } + label="Branch" + value={convoy.feature_branch} + mono + colSpan2 + /> + )} + {convoy.merge_mode && ( + } + label="Merge Mode" + value={convoy.merge_mode} + colSpan2 + /> + )} +
+ + {/* DAG: Bead list with wave structure */} +
+
+ + + Beads ({convoy.beads.length}) + +
+ + {hasDag ? ( +
+ {waves.map((wave, waveIdx) => ( +
+ {waveIdx > 0 && ( +
+ + wave {waveIdx + 1} +
+
+ )} +
+ {wave.map(bead => ( + + ))} +
+
+ ))} +
+ ) : ( +
+ {convoy.beads.map(bead => ( + + ))} +
+ )} +
+
+ ); +} + +function BeadRow({ bead, push }: { bead: ConvoyBead; push: (ref: ResourceRef) => void }) { + return ( + + ); +} + +function MetaCell({ + icon, + label, + value, + mono, + colSpan2, +}: { + icon: React.ReactNode; + label: string; + value: string; + mono?: boolean; + colSpan2?: boolean; +}) { + return ( +
+ {icon} +
+
{label}
+
+ {value} +
+
+
+ ); +} diff --git a/src/lib/gastown/types/router.d.ts b/src/lib/gastown/types/router.d.ts index 5f4eef29e5..4927c0d778 100644 --- a/src/lib/gastown/types/router.d.ts +++ b/src/lib/gastown/types/router.d.ts @@ -451,6 +451,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< sleep_after_minutes?: number | undefined; } | undefined; + staged_convoys_default: boolean; }; meta: object; }>; @@ -487,6 +488,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< sleep_after_minutes?: number | undefined; } | undefined; + staged_convoys_default?: boolean | undefined; }; }; output: { @@ -517,6 +519,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< sleep_after_minutes?: number | undefined; } | undefined; + staged_convoys_default: boolean; }; meta: object; }>; @@ -569,6 +572,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< id: string; title: string; status: 'active' | 'landed'; + staged: boolean; total_beads: number; closed_beads: number; created_by: string | null; @@ -590,6 +594,37 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< }[]; meta: object; }>; + getConvoy: import('@trpc/server').TRPCQueryProcedure<{ + input: { + townId: string; + convoyId: string; + }; + output: { + id: string; + title: string; + status: 'active' | 'landed'; + staged: boolean; + total_beads: number; + closed_beads: number; + created_by: string | null; + created_at: string; + landed_at: string | null; + feature_branch: string | null; + merge_mode: string | null; + beads: { + bead_id: string; + title: string; + status: string; + rig_id: string | null; + assignee_agent_name: string | null; + }[]; + dependency_edges: { + bead_id: string; + depends_on_bead_id: string; + }[]; + } | null; + meta: object; + }>; closeConvoy: import('@trpc/server').TRPCMutationProcedure<{ input: { townId: string; @@ -599,6 +634,38 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< id: string; title: string; status: 'active' | 'landed'; + staged: boolean; + total_beads: number; + closed_beads: number; + created_by: string | null; + created_at: string; + landed_at: string | null; + feature_branch: string | null; + merge_mode: string | null; + beads: { + bead_id: string; + title: string; + status: string; + rig_id: string | null; + assignee_agent_name: string | null; + }[]; + dependency_edges: { + bead_id: string; + depends_on_bead_id: string; + }[]; + } | null; + meta: object; + }>; + startConvoy: import('@trpc/server').TRPCMutationProcedure<{ + input: { + townId: string; + convoyId: string; + }; + output: { + id: string; + title: string; + status: 'active' | 'landed'; + staged: boolean; total_beads: number; closed_beads: number; created_by: string | null; @@ -1318,6 +1385,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute sleep_after_minutes?: number | undefined; } | undefined; + staged_convoys_default: boolean; }; meta: object; }>; @@ -1354,6 +1422,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute sleep_after_minutes?: number | undefined; } | undefined; + staged_convoys_default?: boolean | undefined; }; }; output: { @@ -1384,6 +1453,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute sleep_after_minutes?: number | undefined; } | undefined; + staged_convoys_default: boolean; }; meta: object; }>; @@ -1436,6 +1506,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute id: string; title: string; status: 'active' | 'landed'; + staged: boolean; total_beads: number; closed_beads: number; created_by: string | null; @@ -1457,6 +1528,37 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute }[]; meta: object; }>; + getConvoy: import('@trpc/server').TRPCQueryProcedure<{ + input: { + townId: string; + convoyId: string; + }; + output: { + id: string; + title: string; + status: 'active' | 'landed'; + staged: boolean; + total_beads: number; + closed_beads: number; + created_by: string | null; + created_at: string; + landed_at: string | null; + feature_branch: string | null; + merge_mode: string | null; + beads: { + bead_id: string; + title: string; + status: string; + rig_id: string | null; + assignee_agent_name: string | null; + }[]; + dependency_edges: { + bead_id: string; + depends_on_bead_id: string; + }[]; + } | null; + meta: object; + }>; closeConvoy: import('@trpc/server').TRPCMutationProcedure<{ input: { townId: string; @@ -1466,6 +1568,38 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute id: string; title: string; status: 'active' | 'landed'; + staged: boolean; + total_beads: number; + closed_beads: number; + created_by: string | null; + created_at: string; + landed_at: string | null; + feature_branch: string | null; + merge_mode: string | null; + beads: { + bead_id: string; + title: string; + status: string; + rig_id: string | null; + assignee_agent_name: string | null; + }[]; + dependency_edges: { + bead_id: string; + depends_on_bead_id: string; + }[]; + } | null; + meta: object; + }>; + startConvoy: import('@trpc/server').TRPCMutationProcedure<{ + input: { + townId: string; + convoyId: string; + }; + output: { + id: string; + title: string; + status: 'active' | 'landed'; + staged: boolean; total_beads: number; closed_beads: number; created_by: string | null; diff --git a/src/lib/gastown/types/schemas.d.ts b/src/lib/gastown/types/schemas.d.ts index 88c60ecfd2..98f3440b29 100644 --- a/src/lib/gastown/types/schemas.d.ts +++ b/src/lib/gastown/types/schemas.d.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import type { z } from 'zod'; export declare const TownOutput: z.ZodObject< { id: z.ZodString; @@ -173,6 +173,7 @@ export declare const ConvoyOutput: z.ZodObject< active: 'active'; landed: 'landed'; }>; + staged: z.ZodBoolean; total_beads: z.ZodNumber; closed_beads: z.ZodNumber; created_by: z.ZodNullable; @@ -191,6 +192,7 @@ export declare const ConvoyDetailOutput: z.ZodObject< active: 'active'; landed: 'landed'; }>; + staged: z.ZodBoolean; total_beads: z.ZodNumber; closed_beads: z.ZodNumber; created_by: z.ZodNullable; @@ -599,6 +601,7 @@ export declare const RpcConvoyOutput: z.ZodPipe< active: 'active'; landed: 'landed'; }>; + staged: z.ZodBoolean; total_beads: z.ZodNumber; closed_beads: z.ZodNumber; created_by: z.ZodNullable; @@ -620,6 +623,7 @@ export declare const RpcConvoyDetailOutput: z.ZodPipe< active: 'active'; landed: 'landed'; }>; + staged: z.ZodBoolean; total_beads: z.ZodNumber; closed_beads: z.ZodNumber; created_by: z.ZodNullable; diff --git a/src/routers/admin/gastown-router.ts b/src/routers/admin/gastown-router.ts index 0e51132b8d..021ae2b8c6 100644 --- a/src/routers/admin/gastown-router.ts +++ b/src/routers/admin/gastown-router.ts @@ -135,6 +135,7 @@ const TownConfigRecord = z.object({ alarm_interval_active: z.number().optional(), alarm_interval_idle: z.number().optional(), container: z.object({ sleep_after_minutes: z.number().optional() }).optional(), + staged_convoys_default: z.boolean().optional(), }); const ConvoyDetailRecord = z.object({