From 30094922831dbe1e9a1620c3d8eebbe66a197a99 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Fri, 10 Apr 2026 10:32:50 -0500 Subject: [PATCH 01/33] feat(merges): add dismiss actions for failed beads on Merge Queue page (#2295) * feat(merges): add dismiss actions for failed beads on Merge Queue page - Add individual Dismiss (X) button to each failed bead row in AttentionItemRow - Add bulk 'Dismiss all failed (N)' button to NeedsAttention header area - Fix view button fallback: open MR bead when sourceBead is null (orphaned beads) - Both individual and bulk dismiss call updateBead with status: 'closed' on the MR bead - Dismiss all shows loading spinner and toast on completion/error * fix(merges): use rigId directly in openDrawer to fix TS typecheck --------- Co-authored-by: John Fawcett --- .../[townId]/merges/NeedsAttention.tsx | 140 ++++++++++++++---- 1 file changed, 111 insertions(+), 29 deletions(-) diff --git a/apps/web/src/app/(app)/gastown/[townId]/merges/NeedsAttention.tsx b/apps/web/src/app/(app)/gastown/[townId]/merges/NeedsAttention.tsx index a7fc670317..cdd73bc7bf 100644 --- a/apps/web/src/app/(app)/gastown/[townId]/merges/NeedsAttention.tsx +++ b/apps/web/src/app/(app)/gastown/[townId]/merges/NeedsAttention.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useMemo, Fragment } from 'react'; +import { useState, useMemo, Fragment, useCallback } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useSession } from 'next-auth/react'; import { useGastownTRPC } from '@/lib/gastown/trpc'; @@ -15,7 +15,9 @@ import { Eye, GitBranch, GitMerge, + Loader2, RefreshCw, + X, XCircle, CheckCircle2, Clock, @@ -113,6 +115,8 @@ export function NeedsAttention({ }) { const session = useSession(); const isAdmin = session?.data?.isAdmin ?? false; + const trpc = useGastownTRPC(); + const queryClient = useQueryClient(); const totalCount = data.openPRs.length + data.failedReviews.length + data.stalePRs.length; // Tag each item with its category for rendering @@ -139,6 +143,39 @@ export function NeedsAttention({ return map; }, [allItems]); + const failedItems = useMemo( + () => allItems.filter(({ category }) => category === 'failed').map(({ item }) => item), + [allItems] + ); + + const [isDismissingAll, setIsDismissingAll] = useState(false); + const updateBeadMutation = useMutation(trpc.gastown.updateBead.mutationOptions({})); + + const dismissAllFailed = useCallback(async () => { + if (failedItems.length === 0) return; + setIsDismissingAll(true); + try { + await Promise.all( + failedItems.map(item => + updateBeadMutation.mutateAsync({ + rigId: item.mrBead.rig_id ?? '', + beadId: item.mrBead.bead_id, + status: 'closed', + }) + ) + ); + void queryClient.invalidateQueries({ + queryKey: trpc.gastown.getMergeQueueData.queryKey({ townId }), + }); + toast.success(`Dismissed ${failedItems.length} failed ${failedItems.length === 1 ? 'bead' : 'beads'}`); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + toast.error(`Failed to dismiss all: ${message}`); + } finally { + setIsDismissingAll(false); + } + }, [failedItems, updateBeadMutation, queryClient, trpc, townId]); + if (totalCount === 0) { return (
@@ -150,6 +187,24 @@ export function NeedsAttention({ return (
+ {/* Dismiss all failed button */} + {failedItems.length > 0 && ( +
+ +
+ )} + {/* Convoy groups */} {convoyGroups.map(group => ( @@ -335,6 +390,18 @@ function AttentionItemRow({ }) ); + const dismissMutation = useMutation( + trpc.gastown.updateBead.mutationOptions({ + onSuccess: () => { + invalidateMergeQueue(); + toast.success('Bead dismissed'); + }, + onError: (err: { message: string }) => { + toast.error(`Failed to dismiss: ${err.message}`); + }, + }) + ); + // Fail bead mutation: use adminForceFailBead const failMutation = useMutation( trpc.gastown.adminForceFailBead.mutationOptions({ @@ -349,7 +416,7 @@ function AttentionItemRow({ }) ); - const isPending = retryMutation.isPending || failMutation.isPending; + const isPending = retryMutation.isPending || failMutation.isPending || dismissMutation.isPending; const handleConfirm = () => { if (!confirmAction) return; @@ -388,13 +455,12 @@ function AttentionItemRow({ + <> + + + )}
+ {/* ── Debug ──────────────────────────────────────────── */} + +
+

+ Copies a JSON snapshot of your town configuration for troubleshooting. API + tokens, email addresses, and custom instruction contents are excluded. +

+ +
+
+ {/* ── Danger Zone ──────────────────────────────────────── */}
From b7b52cd7ac7ce7aac75bb06b9c6ed781dec19eda Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Fri, 10 Apr 2026 16:48:23 -0500 Subject: [PATCH 03/33] chore(gastown): remove dead popReviewQueue and update stale comments (#2318) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(gastown): remove dead popReviewQueue and update stale comments Remove popReviewQueue() from review-queue.ts and its wrapper from Town.do.ts — confirmed no callers in the tRPC router, reconciler, or anywhere else. Also remove the Town.do.ts completeReview() wrapper (also had no external callers) and update stale comments across review-queue.ts, Town.do.ts, reconciler.ts, beads.ts, and container-dispatch.ts that referenced old patrol/scheduling functions (feedStrandedConvoys, rehookOrphanedBeads, schedulePendingWork, recoverStuckReviews, witnessPatrol, processReviewQueue) to reflect the current reconciler-based architecture. Closes #1403 * ci: retrigger Kilo Code Review (previous run failed due to transient clone error) * test(gastown): update integration tests to remove removed popReviewQueue/completeReview APIs popReviewQueue() and completeReview() were removed from TownDO as dead code. Update integration tests to use listBeads({ type: 'merge_request' }) instead of popReviewQueue() to observe MR bead state, and remove the regression guard test for completeReview which is no longer testable via the TownDO public API. --------- Co-authored-by: John Fawcett --- services/gastown/src/dos/Town.do.ts | 23 ++--- services/gastown/src/dos/town/beads.ts | 4 +- .../src/dos/town/container-dispatch.ts | 8 +- services/gastown/src/dos/town/reconciler.ts | 4 +- services/gastown/src/dos/town/review-queue.ts | 83 ++++--------------- .../test/integration/review-failure.test.ts | 23 ----- .../test/integration/rig-alarm.test.ts | 14 ++-- .../gastown/test/integration/rig-do.test.ts | 68 +++++---------- 8 files changed, 61 insertions(+), 166 deletions(-) diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index 75a654ccf1..f274cebcfa 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -1670,14 +1670,6 @@ export class TownDO extends DurableObject { await this.escalateToActiveCadence(); } - async popReviewQueue(): Promise { - return reviewQueue.popReviewQueue(this.sql); - } - - async completeReview(entryId: string, status: 'merged' | 'failed'): Promise { - reviewQueue.completeReview(this.sql, entryId, status); - } - async completeReviewWithResult(input: { entry_id: string; status: 'merged' | 'failed' | 'conflict'; @@ -1712,10 +1704,9 @@ export class TownDO extends DurableObject { }); } - // Rework is handled by the normal scheduling path: the failed/conflict + // Rework is handled by the reconciler's scheduling path: the failed/conflict // path in completeReviewWithResult sets the source bead to 'open' with - // assignee cleared. feedStrandedConvoys or rehookOrphanedBeads will - // hook a polecat, and schedulePendingWork will dispatch it. + // assignee cleared. The reconciler will hook a polecat and dispatch it. } async agentDone(agentId: string, input: AgentDoneInput): Promise { @@ -3558,9 +3549,9 @@ export class TownDO extends DurableObject { } // ── Pre-phase: Observe container status for working agents ──────── - // Replaces witnessPatrol's zombie detection. Poll the container for - // each working/stalled agent and emit container_status events. These - // are drained in Phase 0 and applied before reconciliation. + // Poll the container for each working/stalled agent and emit + // container_status events. These are drained in Phase 0 and applied + // before reconciliation. try { const workingAgentRows = z .object({ bead_id: z.string() }) @@ -4487,8 +4478,8 @@ export class TownDO extends DurableObject { // Only count idle+hooked agents as orphaned if they've been idle for // longer than the dispatch cooldown. Agents that were just hooked by - // feedStrandedConvoys or restarted with backoff are legitimately - // waiting for the next scheduler tick. + // the reconciler or restarted with backoff are legitimately waiting + // for the next scheduler tick. const orphanedHooks = Number( [ ...query( diff --git a/services/gastown/src/dos/town/beads.ts b/services/gastown/src/dos/town/beads.ts index 25d76d8a3b..1f1409c337 100644 --- a/services/gastown/src/dos/town/beads.ts +++ b/services/gastown/src/dos/town/beads.ts @@ -421,8 +421,8 @@ export function updateConvoyProgress(sql: SqlStorage, beadId: string, timestamp: if (featureBranch && mergeMode === 'review-then-land') { // Mark the convoy as ready to land by storing a flag in metadata. - // The alarm loop's processReviewQueue will detect this and create - // the final landing MR (feature branch → main). + // The reconciler will detect this and create the final landing + // MR (feature branch → main). query( sql, /* sql */ ` diff --git a/services/gastown/src/dos/town/container-dispatch.ts b/services/gastown/src/dos/town/container-dispatch.ts index e113bc4174..f89300abbd 100644 --- a/services/gastown/src/dos/town/container-dispatch.ts +++ b/services/gastown/src/dos/town/container-dispatch.ts @@ -647,12 +647,12 @@ export async function checkAgentContainerStatus( signal: AbortSignal.timeout(10_000), }); // 404 means the container is running but has no record of this agent - // (e.g. after container eviction). Report as 'not_found' so - // witnessPatrol can immediately reset and redispatch the agent + // (e.g. after container eviction). Report as 'not_found' so the + // reconciler can immediately reset and redispatch the agent // instead of waiting for the 2-hour GUPP timeout. if (response.status === 404) return { status: 'not_found' }; // Non-OK but not 404 — container is having issues but may still - // have the agent running. Return 'unknown' so witnessPatrol doesn't + // have the agent running. Return 'unknown' so the reconciler doesn't // falsely reset a working agent. if (!response.ok) return { status: 'unknown' }; const data: unknown = await response.json(); @@ -668,7 +668,7 @@ export async function checkAgentContainerStatus( return { status: 'unknown' }; } catch { // Timeout, network error, or container starting up — return - // 'unknown' so witnessPatrol doesn't falsely reset working agents. + // 'unknown' so the reconciler doesn't falsely reset working agents. // True zombies will be caught after repeated 'unknown' results // once the GIPP/heartbeat timeout expires. return { status: 'unknown' }; diff --git a/services/gastown/src/dos/town/reconciler.ts b/services/gastown/src/dos/town/reconciler.ts index a7bfcc9acb..111473fd4e 100644 --- a/services/gastown/src/dos/town/reconciler.ts +++ b/services/gastown/src/dos/town/reconciler.ts @@ -539,7 +539,7 @@ export function reconcileAgents(sql: SqlStorage, opts?: { draining?: boolean }): // Agent is working with fresh heartbeat but no hook — it's running // in the container but has no bead to work on (gt_done already ran, // or the hook was cleared by another code path). Set to idle so - // processReviewQueue / schedulePendingWork can use it. + // the reconciler can dispatch it to new work. actions.push({ type: 'transition_agent', agent_id: agent.bead_id, @@ -810,7 +810,7 @@ export function reconcileBeads( }); } - // Rule 2: Idle agents with hooks need dispatch (schedulePendingWork equivalent) + // Rule 2: Idle agents with hooks need dispatch const idleHooked = AgentRow.array().parse([ ...query( sql, diff --git a/services/gastown/src/dos/town/review-queue.ts b/services/gastown/src/dos/town/review-queue.ts index e25819107b..da6402496c 100644 --- a/services/gastown/src/dos/town/review-queue.ts +++ b/services/gastown/src/dos/town/review-queue.ts @@ -208,53 +208,6 @@ export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): v }); } -export function popReviewQueue(sql: SqlStorage): ReviewQueueEntry | null { - // Pop the oldest open MR bead, but skip any whose source bead already - // has another MR in_progress (i.e. a refinery is already reviewing it). - // This prevents popping stale MR beads and triggering failReviewWithRework - // while an active review is in flight for the same source. - // - // The source bead is linked via bead_dependencies (dependency_type='tracks'): - // bead_dependencies.bead_id = MR bead - // bead_dependencies.depends_on_bead_id = source bead - const rows = [ - ...query( - sql, - /* sql */ ` - ${REVIEW_JOIN} - WHERE ${beads.status} = 'open' - AND NOT EXISTS ( - SELECT 1 FROM ${beads} AS active_mr - WHERE active_mr.${beads.columns.type} = 'merge_request' - AND active_mr.${beads.columns.status} = 'in_progress' - AND active_mr.${beads.columns.rig_id} = ${beads.rig_id} - ) - ORDER BY ${beads.created_at} ASC - LIMIT 1 - `, - [] - ), - ]; - - if (rows.length === 0) return null; - const parsed = MergeRequestBeadRecord.parse(rows[0]); - const entry = toReviewQueueEntry(parsed); - - // Mark as running (in_progress) - query( - sql, - /* sql */ ` - UPDATE ${beads} - SET ${beads.columns.status} = 'in_progress', - ${beads.columns.updated_at} = ? - WHERE ${beads.bead_id} = ? - `, - [now(), entry.id] - ); - - return { ...entry, status: 'running', processed_at: now() }; -} - export function completeReview( sql: SqlStorage, entryId: string, @@ -369,8 +322,8 @@ export function completeReviewWithResult( conflict: true, }, }); - // Return source bead to open so the normal scheduling path handles - // rework. Clear assignee so feedStrandedConvoys can match. + // Return source bead to open so the reconciler's scheduling path handles + // rework. Clear assignee so the reconciler can match it for dispatch. const conflictSourceBead = getBead(sql, entry.bead_id); if ( conflictSourceBead && @@ -390,11 +343,10 @@ export function completeReviewWithResult( } } else if (input.status === 'failed') { // Review failed (rework requested): return source bead to open so - // the normal scheduling path (feedStrandedConvoys → hookBead → - // schedulePendingWork → dispatch) handles rework. Clear the stale - // assignee so feedStrandedConvoys can match (requires assignee IS NULL). - // This avoids the fire-and-forget rework dispatch race in TownDO - // where the dispatch fails and rehookOrphanedBeads churn. + // the reconciler's scheduling path handles rework. Clear the stale + // assignee so the reconciler can match it for dispatch (requires + // assignee IS NULL). This avoids a fire-and-forget rework dispatch + // race where the dispatch fails and the bead churns. const sourceBead = getBead(sql, entry.bead_id); if (sourceBead && sourceBead.status !== 'closed' && sourceBead.status !== 'failed') { updateBeadStatus(sql, entry.bead_id, 'open', entry.agent_id); @@ -498,9 +450,8 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu const agent = getAgent(sql, agentId); if (!agent) throw new Error(`Agent ${agentId} not found`); if (!agent.current_hook_bead_id) { - // The agent was unhooked by a recovery path (witnessPatrol, - // rehookOrphanedBeads) between when the agent finished work and - // when it called gt_done. + // The agent was unhooked by a recovery path between when the agent + // finished work and when it called gt_done. // // For refineries, this is critical: the refinery successfully merged // but the hook was cleared by zombie detection. We MUST still complete @@ -648,9 +599,9 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu unhookBead(sql, agentId); // Set refinery to idle immediately — the review is done and the - // refinery is available for new work. Without this, processReviewQueue - // sees the refinery as 'working' and won't pop the next MR bead until - // agentCompleted fires (when the container process eventually exits). + // refinery is available for new work. Without this, the reconciler + // sees the refinery as 'working' and won't dispatch the next MR bead + // until agentCompleted fires (when the container process eventually exits). updateAgentStatus(sql, agentId, 'idle'); return; } @@ -659,7 +610,7 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu if (!agent.rig_id) { console.warn( - `[review-queue] agentDone: agent ${agentId} has null rig_id — review entry may fail in processReviewQueue` + `[review-queue] agentDone: agent ${agentId} has null rig_id — review entry may fail in submitToReviewQueue` ); } @@ -718,13 +669,13 @@ export function agentCompleted( // NEVER fail or unhook a refinery from agentCompleted. // agentCompleted races with gt_done: the process exits, the // container sends /completed, but gt_done's HTTP request may - // still be in flight. If we unhook here, recoverStuckReviews - // can fire between agentCompleted and gt_done, resetting the - // MR bead that's about to be closed by gt_done. + // still be in flight. If we unhook here, a recovery path can + // fire between agentCompleted and gt_done, resetting the MR bead + // that's about to be closed by gt_done. // // Leave the hook intact. gt_done will close + unhook if the - // merge succeeded. recoverStuckReviews (which checks for - // status='working') handles the case where gt_done never arrives. + // merge succeeded. The reconciler (which checks for status='working') + // handles the case where gt_done never arrives. // // No-op for the bead — just fall through to mark agent idle. } else { diff --git a/services/gastown/test/integration/review-failure.test.ts b/services/gastown/test/integration/review-failure.test.ts index d5b7773c00..8fcb07cb0e 100644 --- a/services/gastown/test/integration/review-failure.test.ts +++ b/services/gastown/test/integration/review-failure.test.ts @@ -182,29 +182,6 @@ describe('Review failure paths — convoy progress and source bead recovery', () }); }); - // ── Direct completeReview leaves source bead orphaned (regression) ─ - - describe('completeReview bypass (regression guard)', () => { - it('should leave source bead stuck in in_review when completeReview is called directly', async () => { - const { beadId, mrBeadId } = await setupConvoyWithMR(); - - // Call completeReview directly (the OLD broken path) — - // this is the raw SQL update that bypasses lifecycle events. - // We use this to verify the regression scenario. - await town.completeReview(mrBeadId, 'failed'); - - // MR bead should be failed - const mrBead = await town.getBeadAsync(mrBeadId); - expect(mrBead?.status).toBe('failed'); - - // Source bead is STILL in_review — this is the bug this PR fixes - // in processReviewQueue. The direct completeReview call doesn't - // return the source bead to in_progress. - const sourceBead = await town.getBeadAsync(beadId); - expect(sourceBead?.status).toBe('in_review'); - }); - }); - // ── Source bead in_review after agentDone ────────────────────────── describe('agentDone transitions source bead to in_review', () => { diff --git a/services/gastown/test/integration/rig-alarm.test.ts b/services/gastown/test/integration/rig-alarm.test.ts index a80cfc6b5f..1ec79e6962 100644 --- a/services/gastown/test/integration/rig-alarm.test.ts +++ b/services/gastown/test/integration/rig-alarm.test.ts @@ -158,9 +158,10 @@ describe('Town DO Alarm', () => { // fail gracefully and mark the review as 'failed' await runDurableObjectAlarm(town); - // The pending entry should have been popped (no more pending entries) - const nextEntry = await town.popReviewQueue(); - expect(nextEntry).toBeNull(); + // The MR bead should no longer be open (alarm processed it) + const mrBeads = await town.listBeads({ type: 'merge_request' }); + expect(mrBeads).toHaveLength(1); + expect(mrBeads[0].status).not.toBe('open'); }); }); @@ -293,9 +294,10 @@ describe('Town DO Alarm', () => { // (will fail at container level but that's expected in tests) await runDurableObjectAlarm(town); - // Review queue entry should have been popped and processed (failed in test env) - const reviewEntry = await town.popReviewQueue(); - expect(reviewEntry).toBeNull(); + // MR bead should have been picked up and processed (failed in test env) + const mrBeads = await town.listBeads({ type: 'merge_request' }); + expect(mrBeads).toHaveLength(1); + expect(mrBeads[0].status).not.toBe('open'); }); }); }); diff --git a/services/gastown/test/integration/rig-do.test.ts b/services/gastown/test/integration/rig-do.test.ts index eb22196fd0..221f5bce67 100644 --- a/services/gastown/test/integration/rig-do.test.ts +++ b/services/gastown/test/integration/rig-do.test.ts @@ -356,7 +356,7 @@ describe('TownDO', () => { // ── Review Queue ─────────────────────────────────────────────────────── describe('review queue', () => { - it('should submit to and pop from review queue', async () => { + it('should submit to review queue and create an open merge_request bead', async () => { const agent = await town.registerAgent({ role: 'polecat', name: 'P1', @@ -373,40 +373,12 @@ describe('TownDO', () => { summary: 'Fixed the widget', }); - const entry = await town.popReviewQueue(); - expect(entry).toBeDefined(); - expect(entry?.branch).toBe('feature/fix-widget'); - expect(entry?.pr_url).toBe('https://github.com/org/repo/pull/1'); - expect(entry?.status).toBe('running'); - - // Pop again should return null (nothing pending) - const empty = await town.popReviewQueue(); - expect(empty).toBeNull(); - }); - - it('should complete a review', async () => { - const agent = await town.registerAgent({ - role: 'polecat', - name: 'P1', - identity: `complete-review-${townName}`, - }); - const bead = await town.createBead({ type: 'issue', title: 'Review complete' }); - - await town.submitToReviewQueue({ - agent_id: agent.id, - bead_id: bead.bead_id, - rig_id: 'test-rig', - branch: 'feature/fix', - }); - - const entry = await town.popReviewQueue(); - expect(entry).toBeDefined(); - - await town.completeReview(entry!.id, 'merged'); - - // Pop again should be null - const empty = await town.popReviewQueue(); - expect(empty).toBeNull(); + // submitToReviewQueue creates an open merge_request bead + const mrBeads = await town.listBeads({ type: 'merge_request' }); + expect(mrBeads).toHaveLength(1); + expect(mrBeads[0].status).toBe('open'); + expect(mrBeads[0].metadata?.pr_url).toBe('https://github.com/org/repo/pull/1'); + expect(mrBeads[0].metadata?.source_bead_id).toBe(bead.bead_id); }); it('should close bead on successful merge via completeReviewWithResult', async () => { @@ -424,11 +396,12 @@ describe('TownDO', () => { branch: 'feature/merge-test', }); - const entry = await town.popReviewQueue(); - expect(entry).toBeDefined(); + const mrBeads = await town.listBeads({ type: 'merge_request' }); + expect(mrBeads).toHaveLength(1); + const mrBeadId = mrBeads[0].bead_id; await town.completeReviewWithResult({ - entry_id: entry!.id, + entry_id: mrBeadId, status: 'merged', message: 'Merge successful', commit_sha: 'abc123', @@ -439,9 +412,9 @@ describe('TownDO', () => { expect(updatedBead?.status).toBe('closed'); expect(updatedBead?.closed_at).toBeDefined(); - // Review queue should be empty - const empty = await town.popReviewQueue(); - expect(empty).toBeNull(); + // MR bead should be closed + const updatedMr = await town.getBeadAsync(mrBeadId); + expect(updatedMr?.status).toBe('closed'); }); it('should create escalation bead on merge conflict via completeReviewWithResult', async () => { @@ -459,11 +432,12 @@ describe('TownDO', () => { branch: 'feature/conflict-test', }); - const entry = await town.popReviewQueue(); - expect(entry).toBeDefined(); + const mrBeads = await town.listBeads({ type: 'merge_request' }); + expect(mrBeads).toHaveLength(1); + const mrBeadId = mrBeads[0].bead_id; await town.completeReviewWithResult({ - entry_id: entry!.id, + entry_id: mrBeadId, status: 'conflict', message: 'CONFLICT (content): Merge conflict in src/index.ts', }); @@ -484,9 +458,9 @@ describe('TownDO', () => { agent_id: agent.id, }); - // Review queue entry should be marked as failed - const empty = await town.popReviewQueue(); - expect(empty).toBeNull(); + // MR bead should be marked as failed + const updatedMr = await town.getBeadAsync(mrBeadId); + expect(updatedMr?.status).toBe('failed'); }); }); From d6a58b3d16be6a1a77ee7fb4ded68302332061d6 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Fri, 10 Apr 2026 17:01:14 -0500 Subject: [PATCH 04/33] fix(gastown): prevent triage batch bead dispatch loop with wrong system prompt (#2321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(gastown): prevent triage batch bead dispatch loop with wrong system prompt Option A: Mark triage batch bead as in_progress immediately after hookBead() in maybeDispatchTriageAgent(), before awaiting startAgentInContainer(). This prevents reconciler Rule 2 (idle agent + open hooked bead → dispatch_agent) from re-dispatching the triage bead with the generic polecat prompt on the next tick when container start fails. Rule 3 (stale in_progress, 5-min timeout) resets it to open for a clean retry via maybeDispatchTriageAgent. Option B (defense-in-depth): In applyActionCtx.dispatchAgent, detect triage batch beads (gt:triage label + created_by='patrol') and inject the triage system prompt, ensuring the polecat gets the correct tools even if Rule 2 somehow fires against an open triage batch bead. Fixes #1958 * fix(gastown): set rig_id on triage batch bead so reconciler Rule 1 can re-dispatch after timeout Without rig_id, when Rule 3 resets an abandoned in_progress triage batch bead to 'open', Rule 1 skips it (requires rig_id IS NOT NULL). This left the bead permanently 'open', blocking maybeDispatchTriageAgent from creating a replacement. Setting rig_id ensures Rule 1 can re-dispatch the existing bead (with triage system prompt injected via Option B). --------- Co-authored-by: John Fawcett --- services/gastown/src/dos/Town.do.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index f274cebcfa..97398f20e9 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -343,6 +343,17 @@ export class TownDO extends DurableObject { }); } + // Option B (defense-in-depth): If the reconciler re-dispatches an + // open triage batch bead (gt:triage, created_by='patrol') — e.g. + // because Option A's in_progress transition was somehow bypassed — + // inject the triage system prompt so the polecat gets the correct + // tools and instructions instead of the generic polecat prompt. + if (bead.labels.includes(patrol.TRIAGE_BATCH_LABEL) && bead.created_by === 'patrol') { + const pendingRequests = patrol.listPendingTriageRequests(this.sql); + const { buildTriageSystemPrompt } = await import('../prompts/triage-system.prompt'); + systemPromptOverride = buildTriageSystemPrompt(pendingRequests); + } + return scheduling.dispatchAgent(schedulingCtx, agent, bead, { systemPromptOverride, }); @@ -4055,6 +4066,9 @@ export class TownDO extends DurableObject { const systemPrompt = buildTriageSystemPrompt(pendingRequests); // Only now create the synthetic bead — preconditions are verified. + // Set rig_id so that if Rule 3 resets this bead to 'open' after a + // dispatch timeout, Rule 1 of the reconciler can pick it up and + // re-dispatch it (with the correct triage system prompt via Option B). const triageBead = beadOps.createBead(this.sql, { type: 'issue', title: `Triage batch: ${pendingCount} request(s)`, @@ -4062,11 +4076,20 @@ export class TownDO extends DurableObject { priority: 'high', labels: [patrol.TRIAGE_BATCH_LABEL], created_by: 'patrol', + rig_id: rigId, }); const triageAgent = agents.getOrCreateAgent(this.sql, 'polecat', rigId, this.townId); agents.hookBead(this.sql, triageAgent.id, triageBead.bead_id); + // Option A: Immediately mark the triage batch bead as in_progress so + // the reconciler's Rule 2 (idle agent + open hooked bead → dispatch_agent) + // does not re-fire on the next tick if the container start fails. Rule 3 + // (stale in_progress bead + no working agent + 5-min timeout) will reset + // it back to open if the dispatch fails, allowing a clean retry via + // maybeDispatchTriageAgent with the correct triage system prompt. + beadOps.updateBeadStatus(this.sql, triageBead.bead_id, 'in_progress', triageAgent.id); + const started = await dispatch.startAgentInContainer(this.env, this.ctx.storage, { townId: this.townId, rigId, From 82be7ec3cfb49d14462b8dc8d808745d6ff7c455 Mon Sep 17 00:00:00 2001 From: Breno Colom Date: Mon, 13 Apr 2026 16:56:26 +0200 Subject: [PATCH 05/33] feat(gastown): add cmake and pkg-config to container images (#2060) Add remaining build-essentials packages (cmake, pkg-config) to both prod and dev Dockerfiles. build-essential and libssl-dev were already present. --- services/gastown/container/Dockerfile | 4 +++- services/gastown/container/Dockerfile.dev | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/services/gastown/container/Dockerfile b/services/gastown/container/Dockerfile index b835a33329..225be7ee20 100644 --- a/services/gastown/container/Dockerfile +++ b/services/gastown/container/Dockerfile @@ -4,7 +4,7 @@ FROM oven/bun:1-slim # Package categories: # version control: git, git-lfs # network/download: curl, wget, ca-certificates, gnupg, unzip -# build toolchain: build-essential, autoconf +# build toolchain: build-essential, autoconf, cmake, pkg-config # search tools: ripgrep, jq # compression: bzip2, zstd # SSL/crypto: libssl-dev, libffi-dev @@ -27,6 +27,8 @@ RUN apt-get update && \ unzip \ build-essential \ autoconf \ + cmake \ + pkg-config \ ripgrep \ jq \ bzip2 \ diff --git a/services/gastown/container/Dockerfile.dev b/services/gastown/container/Dockerfile.dev index a4bebc5dbf..b8680462bd 100644 --- a/services/gastown/container/Dockerfile.dev +++ b/services/gastown/container/Dockerfile.dev @@ -4,7 +4,7 @@ FROM --platform=linux/arm64 oven/bun:1-slim # Package categories: # version control: git, git-lfs # network/download: curl, wget, ca-certificates, gnupg, unzip -# build toolchain: build-essential, autoconf +# build toolchain: build-essential, autoconf, cmake, pkg-config # search tools: ripgrep, jq # compression: bzip2, zstd # SSL/crypto: libssl-dev, libffi-dev @@ -27,6 +27,8 @@ RUN apt-get update && \ unzip \ build-essential \ autoconf \ + cmake \ + pkg-config \ ripgrep \ jq \ bzip2 \ From d7a83b57fb3972aed00097715f22824e29da451a Mon Sep 17 00:00:00 2001 From: Breno Colom Date: Mon, 13 Apr 2026 16:56:37 +0200 Subject: [PATCH 06/33] feat(gastown): add Java JDK to container images (#2066) Install default-jdk (OpenJDK) in both prod and dev Dockerfiles to support Java project builds and runtime. --- services/gastown/container/Dockerfile | 2 ++ services/gastown/container/Dockerfile.dev | 2 ++ 2 files changed, 4 insertions(+) diff --git a/services/gastown/container/Dockerfile b/services/gastown/container/Dockerfile index 225be7ee20..44afc48b6d 100644 --- a/services/gastown/container/Dockerfile +++ b/services/gastown/container/Dockerfile @@ -16,6 +16,7 @@ FROM oven/bun:1-slim # C++ stdlib: libc++1 # math: libgmp-dev # timezone data: tzdata +# Java: default-jdk RUN apt-get update && \ apt-get install -y --no-install-recommends \ git \ @@ -49,6 +50,7 @@ RUN apt-get update && \ libc++1 \ libgmp-dev \ tzdata \ + default-jdk \ && curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ diff --git a/services/gastown/container/Dockerfile.dev b/services/gastown/container/Dockerfile.dev index b8680462bd..5e5f00ace4 100644 --- a/services/gastown/container/Dockerfile.dev +++ b/services/gastown/container/Dockerfile.dev @@ -16,6 +16,7 @@ FROM --platform=linux/arm64 oven/bun:1-slim # C++ stdlib: libc++1 # math: libgmp-dev # timezone data: tzdata +# Java: default-jdk RUN apt-get update && \ apt-get install -y --no-install-recommends \ git \ @@ -49,6 +50,7 @@ RUN apt-get update && \ libc++1 \ libgmp-dev \ tzdata \ + default-jdk \ && curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ From a2946d898e0a7d7a91d21652801c4e2e79c2b396 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Mon, 13 Apr 2026 10:22:15 -0500 Subject: [PATCH 07/33] fix(gastown): propagate custom env_vars to running containers on settings save (#2366) * fix(gastown): propagate custom env_vars to running containers on settings save Three gaps fixed: 1. syncTownConfigToProcessEnv() now applies custom env_vars from town config to process.env, with tracking of previously-applied keys so removed vars are deleted from process.env. 2. syncConfigToContainer() now persists custom env_vars to TownContainerDO storage (via container.setEnvVar/deleteEnvVar) so they survive container restarts. Previously-persisted custom keys are tracked in DO storage and cleaned up on removal. 3. updateAgentModel() hot-swap now overlays fresh custom env_vars from getCurrentTownConfig() over the stale startupEnv snapshot. Infra keys in LIVE_ENV_KEYS always take precedence. * fix(gastown): guard custom env_vars against reserved key override - control-server: export getLastAppliedEnvVarKeys() for process-manager - process-manager: delete stale custom keys from hotSwapEnv on hot-swap - Town.do: skip RESERVED_ENV_KEYS when setting custom env_vars on container Addresses 3 review warnings about custom env_vars overriding infra keys. * fix: skip reserved env keys in prevCustomKeys cleanup loop prevCustomKeys may contain reserved keys persisted by the previous implementation (before the RESERVED_ENV_KEYS filter was added). Without this guard the cleanup loop would delete managed infra values like KILOCODE_TOKEN that were just written by envMapping. --------- Co-authored-by: John Fawcett --- .../gastown/container/src/control-server.ts | 49 +++++++++++++++++++ .../gastown/container/src/process-manager.ts | 28 +++++++++++ services/gastown/src/dos/Town.do.ts | 47 ++++++++++++++++++ 3 files changed, 124 insertions(+) diff --git a/services/gastown/container/src/control-server.ts b/services/gastown/container/src/control-server.ts index b56eb97e64..9aef9defbd 100644 --- a/services/gastown/container/src/control-server.ts +++ b/services/gastown/container/src/control-server.ts @@ -43,11 +43,39 @@ const TownConfigHeader = z.record(z.string(), z.unknown()); // Used as a fallback by code that runs outside a request context (e.g. background tasks). let lastKnownTownConfig: Record | null = null; +// Track which custom env var keys were applied last sync so removed keys can be cleared. +let lastAppliedEnvVarKeys = new Set(); + +// Env keys managed by the control plane that custom env_vars must never override. +// If a custom key collides with a reserved key, the infra value wins and the +// custom value is silently ignored — matching the !(key in env) guard in buildAgentEnv. +const RESERVED_ENV_KEYS = new Set([ + 'KILOCODE_TOKEN', + 'GIT_TOKEN', + 'GITHUB_TOKEN', + 'GITLAB_TOKEN', + 'GITLAB_INSTANCE_URL', + 'GITHUB_CLI_PAT', + 'GH_TOKEN', + 'GASTOWN_GIT_AUTHOR_NAME', + 'GASTOWN_GIT_AUTHOR_EMAIL', + 'GASTOWN_DISABLE_AI_COAUTHOR', + 'GASTOWN_ORGANIZATION_ID', + 'GASTOWN_CONTAINER_TOKEN', + 'GASTOWN_SESSION_TOKEN', + 'GASTOWN_API_URL', +]); + /** Get the latest town config delivered via X-Town-Config header. */ export function getCurrentTownConfig(): Record | null { return lastKnownTownConfig; } +/** Get the set of custom env var keys applied in the last sync. */ +export function getLastAppliedEnvVarKeys(): Set { + return lastAppliedEnvVarKeys; +} + /** * Sync config-derived env vars from the last-known town config into * process.env. Safe to call at any time — no-ops when no config is cached. @@ -102,6 +130,27 @@ function syncTownConfigToProcessEnv(): void { } else { delete process.env.GASTOWN_ORGANIZATION_ID; } + + // Apply custom env_vars from the town config. Reserved infra keys are + // skipped so the control-plane values always take precedence — matching the + // !(key in env) guard in buildAgentEnv. + const rawEnvVars = cfg.env_vars; + const customEnvVars: Record = + rawEnvVars !== null && typeof rawEnvVars === 'object' && !Array.isArray(rawEnvVars) + ? (rawEnvVars as Record) + : {}; + const newCustomKeys = new Set(Object.keys(customEnvVars)); + // Remove keys that were present in the previous sync but are gone now. + // Skip reserved keys — deleting those would wipe a control-plane value. + for (const key of lastAppliedEnvVarKeys) { + if (!newCustomKeys.has(key) && !RESERVED_ENV_KEYS.has(key)) delete process.env[key]; + } + // Apply current custom env vars, skipping reserved keys. + for (const [key, value] of Object.entries(customEnvVars)) { + if (RESERVED_ENV_KEYS.has(key)) continue; + process.env[key] = String(value); + } + lastAppliedEnvVarKeys = newCustomKeys; } export const app = new Hono(); diff --git a/services/gastown/container/src/process-manager.ts b/services/gastown/container/src/process-manager.ts index 77aa4bb411..659a2c2dea 100644 --- a/services/gastown/container/src/process-manager.ts +++ b/services/gastown/container/src/process-manager.ts @@ -12,6 +12,7 @@ import * as fs from 'node:fs/promises'; import type { ManagedAgent, StartAgentRequest } from './types'; import { reportAgentCompleted, reportMayorWaiting } from './completion-reporter'; import { buildKiloConfigContent } from './agent-runner'; +import { getCurrentTownConfig, getLastAppliedEnvVarKeys } from './control-server'; import { log } from './logger'; const MANAGER_LOG = '[process-manager]'; @@ -1264,6 +1265,33 @@ export async function updateAgentModel( if (live) hotSwapEnv[key] = live; } + // Overlay custom env_vars from the town config so hot-swap picks up + // values that were added/changed after the initial dispatch. Infra + // keys in LIVE_ENV_KEYS always take precedence (they were already + // populated from process.env above), so custom vars cannot override. + const freshConfig = getCurrentTownConfig(); + const freshEnvVars = freshConfig?.env_vars; + const freshCustomKeySet = new Set(); + if (freshEnvVars !== null && typeof freshEnvVars === 'object' && !Array.isArray(freshEnvVars)) { + for (const [key, value] of Object.entries(freshEnvVars as Record)) { + if (LIVE_ENV_KEYS.has(key)) continue; + freshCustomKeySet.add(key); + if (value !== undefined && value !== null) { + hotSwapEnv[key] = String(value); + } else { + delete hotSwapEnv[key]; + } + } + } + // Remove stale custom env vars — keys that were applied in a previous + // sync but are no longer in the town config. Without this, startupEnv + // keeps carrying deleted custom keys through every hot-swap. + for (const key of getLastAppliedEnvVarKeys()) { + if (!freshCustomKeySet.has(key) && !LIVE_ENV_KEYS.has(key)) { + delete hotSwapEnv[key]; + } + } + // Re-derive GH_TOKEN from live values using the same priority chain // as buildAgentEnv: GITHUB_CLI_PAT > GIT_TOKEN > GITHUB_TOKEN. // syncConfigToContainer updates these on process.env, but buildAgentEnv diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index 97398f20e9..695c87ea77 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -797,6 +797,53 @@ export class TownDO extends DurableObject { } } + // Persist custom env_vars to DO storage so they survive container restarts. + // Compare against the previously-persisted set of keys to clear removed ones. + // Reserved infra keys are never overwritten or deleted — infra values always win. + const RESERVED_ENV_KEYS = new Set([ + 'KILOCODE_TOKEN', + 'GIT_TOKEN', + 'GITHUB_TOKEN', + 'GITLAB_TOKEN', + 'GITLAB_INSTANCE_URL', + 'GITHUB_CLI_PAT', + 'GH_TOKEN', + 'GASTOWN_GIT_AUTHOR_NAME', + 'GASTOWN_GIT_AUTHOR_EMAIL', + 'GASTOWN_DISABLE_AI_COAUTHOR', + 'GASTOWN_ORGANIZATION_ID', + 'GASTOWN_CONTAINER_TOKEN', + 'GASTOWN_SESSION_TOKEN', + 'GASTOWN_API_URL', + ]); + const CUSTOM_ENV_KEYS_STORAGE_KEY = 'container:custom_env_var_keys'; + const prevCustomKeys: string[] = + (await this.ctx.storage.get(CUSTOM_ENV_KEYS_STORAGE_KEY)) ?? []; + const newCustomKeys = Object.keys(townConfig.env_vars).filter( + key => !RESERVED_ENV_KEYS.has(key) + ); + const newCustomKeySet = new Set(newCustomKeys); + + for (const key of prevCustomKeys) { + if (RESERVED_ENV_KEYS.has(key)) continue; + if (!newCustomKeySet.has(key)) { + try { + await container.deleteEnvVar(key); + } catch (err) { + console.warn(`[Town.do] syncConfigToContainer: delete custom ${key} failed:`, err); + } + } + } + for (const [key, value] of Object.entries(townConfig.env_vars)) { + if (RESERVED_ENV_KEYS.has(key)) continue; + try { + await container.setEnvVar(key, value); + } catch (err) { + console.warn(`[Town.do] syncConfigToContainer: set custom ${key} failed:`, err); + } + } + await this.ctx.storage.put(CUSTOM_ENV_KEYS_STORAGE_KEY, newCustomKeys); + // Phase 2: Push to the running container's process.env via the // /sync-config endpoint. The X-Town-Config header delivers the // full config; the endpoint applies CONFIG_ENV_MAP to process.env. From 753017b1575954420645f5fa001b730713b3e701 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Mon, 13 Apr 2026 10:45:01 -0500 Subject: [PATCH 08/33] chore(gastown): remove dead code from patrol/scheduling/review-queue (#1403) (#2339) chore(gastown): remove dead GUPP_WARN_MS export and update stale patrol/queue comments - Remove unused GUPP_WARN_MS constant export from patrol.ts (never referenced outside the file) - Update completion-reporter.ts JSDoc: replace stale witnessPatrol/schedulePendingWork references with reconciler-based description - Update control-server.ts comments: replace stale processReviewQueue/recoverStuckReviews references with current TownDO terminology Part of issue #1403 dead code cleanup. Co-authored-by: John Fawcett --- services/gastown/container/src/completion-reporter.ts | 7 +++---- services/gastown/container/src/control-server.ts | 8 ++++---- services/gastown/src/dos/town/patrol.ts | 2 -- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/services/gastown/container/src/completion-reporter.ts b/services/gastown/container/src/completion-reporter.ts index 7a7766e61f..04ece97b19 100644 --- a/services/gastown/container/src/completion-reporter.ts +++ b/services/gastown/container/src/completion-reporter.ts @@ -1,8 +1,7 @@ /** - * Reports agent completion/failure back to the Rig DO via the Gastown - * worker API. This closes the bead and unhooks the agent, preventing - * the infinite retry loop where witnessPatrol resets the agent to idle - * and schedulePendingWork re-dispatches it. + * Reports agent completion/failure back to the Gastown worker API. + * This closes the bead and unhooks the agent so the reconciler does not + * re-dispatch it. */ import type { ManagedAgent } from './types'; diff --git a/services/gastown/container/src/control-server.ts b/services/gastown/container/src/control-server.ts index 9aef9defbd..de43a95463 100644 --- a/services/gastown/container/src/control-server.ts +++ b/services/gastown/container/src/control-server.ts @@ -520,9 +520,9 @@ app.post('/repos/setup', async c => { // POST /git/merge // Deterministic merge of a polecat branch into the target branch. -// Called by the Rig DO's processReviewQueue → startMergeInContainer. -// Runs the merge synchronously and reports the result back to the Rig DO -// via a callback to the completeReview endpoint. +// Called by the TownDO's startMergeInContainer. +// Runs the merge synchronously and reports the result back via a callback +// to the completeReview endpoint. app.post('/git/merge', async c => { const body: unknown = await c.req.json().catch(() => null); const parsed = MergeRequest.safeParse(body); @@ -588,7 +588,7 @@ app.post('/git/merge', async c => { } }; - // Fire and forget — the Rig DO will time out stuck entries via recoverStuckReviews + // Fire and forget — the TownDO will time out stuck entries via its alarm loop doMerge().catch(err => { console.error(`Merge failed for entry ${req.entryId}:`, err); }); diff --git a/services/gastown/src/dos/town/patrol.ts b/services/gastown/src/dos/town/patrol.ts index b1617599a1..2172623233 100644 --- a/services/gastown/src/dos/town/patrol.ts +++ b/services/gastown/src/dos/town/patrol.ts @@ -17,8 +17,6 @@ const LOG = '[patrol]'; // ── Thresholds ────────────────────────────────────────────────────── -/** First GUPP warning (existing behavior) */ -export const GUPP_WARN_MS = 30 * 60_000; // 30 min /** Escalate to mayor after second threshold */ export const GUPP_ESCALATE_MS = 60 * 60_000; // 1h /** Force-stop agent after third threshold */ From bf3ab644899814caab67bf243063c3e5032758b7 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Mon, 13 Apr 2026 11:11:02 -0500 Subject: [PATCH 09/33] fix(gastown): break create_landing_mr infinite loop (#2260) (#2371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(gastown): break create_landing_mr infinite loop (#2260) Add circuit breaker for landing MR creation to prevent runaway retry loops when convoys have no PR URLs. A town accumulated 5,335 failed actions over 41 hours before this fix. - Fix 1: Deduplicate MR bead creation — skip if an open/in_progress landing MR already exists for the convoy - Fix 2: Max 5 landing MR attempts with exponential cooldown (30s base, 30min cap), fail the convoy when exhausted - Fix 3: PR URL validation guard — skip landing MR creation when no tracked beads have a pr_url - Fix 4: Move convoy fail check before update_convoy_progress to prevent the race where progress updates are emitted for convoys about to be failed/closed Store landing_mr_attempts and last_landing_mr_attempt_at in the convoy bead's metadata JSON field (no schema migration needed). Add FailConvoy action type for explicit convoy failure. * fix(gastown): move max-attempts check after landing MR status lookup The max landing MR attempts guard was firing before checking whether the final landing MR was still active or already merged, making the last allowed attempt impossible to succeed. Now we check landing MR status first and only fail the convoy when no landing MR is active or merged. --------- Co-authored-by: John Fawcett --- services/gastown/src/dos/town/actions.ts | 45 +++- services/gastown/src/dos/town/reconciler.ts | 223 +++++++++++++------- 2 files changed, 189 insertions(+), 79 deletions(-) diff --git a/services/gastown/src/dos/town/actions.ts b/services/gastown/src/dos/town/actions.ts index d7fdfee532..bb1482d035 100644 --- a/services/gastown/src/dos/town/actions.ts +++ b/services/gastown/src/dos/town/actions.ts @@ -132,6 +132,12 @@ const CloseConvoy = z.object({ convoy_id: z.string(), }); +const FailConvoy = z.object({ + type: z.literal('fail_convoy'), + convoy_id: z.string(), + reason: z.string(), +}); + // ── Side effects (deferred) ───────────────────────────────────────── const DispatchAgent = z.object({ @@ -206,6 +212,7 @@ export const Action = z.discriminatedUnion('type', [ UpdateConvoyProgress, SetConvoyReadyToLand, CloseConvoy, + FailConvoy, // Side effects DispatchAgent, StopAgent, @@ -239,6 +246,7 @@ export type DeleteAgent = z.infer; export type UpdateConvoyProgress = z.infer; export type SetConvoyReadyToLand = z.infer; export type CloseConvoy = z.infer; +export type FailConvoy = z.infer; export type DispatchAgent = z.infer; export type StopAgent = z.infer; export type PollPr = z.infer; @@ -397,7 +405,22 @@ export function applyAction(ctx: ApplyActionContext, action: Action): (() => Pro } case 'create_landing_mr': { - // Create an MR bead for the landing merge (feature branch → main) + const timestamp = now(); + query( + sql, + /* sql */ ` + UPDATE ${beads} + SET ${beads.columns.metadata} = json_set( + COALESCE(${beads.columns.metadata}, '{}'), + '$.landing_mr_attempts', + COALESCE(json_extract(${beads.columns.metadata}, '$.landing_mr_attempts'), 0) + 1, + '$.last_landing_mr_attempt_at', ? + ), + ${beads.columns.updated_at} = ? + WHERE ${beads.bead_id} = ? + `, + [timestamp, timestamp, action.convoy_id] + ); reviewQueue.submitToReviewQueue(sql, { agent_id: 'system', bead_id: action.convoy_id, @@ -592,7 +615,6 @@ export function applyAction(ctx: ApplyActionContext, action: Action): (() => Pro } case 'close_convoy': { - // Use updateBeadStatus for terminal state guard + bead event logging beadOps.updateBeadStatus(sql, action.convoy_id, 'closed', 'system'); query( sql, @@ -606,6 +628,25 @@ export function applyAction(ctx: ApplyActionContext, action: Action): (() => Pro return null; } + case 'fail_convoy': { + beadOps.updateBeadStatus(sql, action.convoy_id, 'failed', 'system'); + query( + sql, + /* sql */ ` + UPDATE ${beads} + SET ${beads.columns.metadata} = json_set( + COALESCE(${beads.columns.metadata}, '{}'), + '$.failureReason', 'landing_mr_exhausted', + '$.failureMessage', ? + ), + ${beads.columns.updated_at} = ? + WHERE ${beads.bead_id} = ? + `, + [action.reason, now(), action.convoy_id] + ); + return null; + } + // ── Side effects (deferred) ───────────────────────────────── case 'dispatch_agent': { diff --git a/services/gastown/src/dos/town/reconciler.ts b/services/gastown/src/dos/town/reconciler.ts index 111473fd4e..91884509e5 100644 --- a/services/gastown/src/dos/town/reconciler.ts +++ b/services/gastown/src/dos/town/reconciler.ts @@ -45,6 +45,15 @@ const CIRCUIT_BREAKER_FAILURE_THRESHOLD = 20; /** Window in minutes for counting dispatch failures. */ const CIRCUIT_BREAKER_WINDOW_MINUTES = 30; +/** Max landing MR creation attempts before failing the convoy (#2260). */ +const MAX_LANDING_MR_ATTEMPTS = 5; + +/** Base cooldown for landing MR retry: min(2^attempts * BASE, MAX) (#2260). */ +const LANDING_MR_COOLDOWN_BASE_MS = 30_000; // 30s + +/** Max cooldown for landing MR retry (#2260). */ +const LANDING_MR_COOLDOWN_MAX_MS = 30 * 60_000; // 30 min + /** * Town-level dispatch circuit breaker. Counts beads with at least one * dispatch attempt in the recent window that have not yet closed @@ -1723,14 +1732,19 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { if (progressRows.length === 0) continue; const { closed_count, total_count } = progressRows[0]; - // Update progress if stale - if (closed_count !== convoy.closed_beads) { - actions.push({ - type: 'update_convoy_progress', - convoy_id: convoy.bead_id, - closed_beads: closed_count, - }); + // Parse convoy metadata for landing MR tracking fields (#2260) + let parsedMeta: Record = {}; + try { + parsedMeta = JSON.parse(convoy.metadata) as Record; + } catch { + /* ignore */ } + const landingMrAttempts = + typeof parsedMeta.landing_mr_attempts === 'number' ? parsedMeta.landing_mr_attempts : 0; + const lastLandingMrAttemptAt = + typeof parsedMeta.last_landing_mr_attempt_at === 'string' + ? parsedMeta.last_landing_mr_attempt_at + : null; // Check for in-flight MR beads (open or in_progress) for tracked issue beads const inFlightMrCount = z @@ -1759,31 +1773,36 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { const hasInFlightReviews = (inFlightMrCount[0]?.cnt ?? 0) > 0; // Check if all beads done - if (closed_count >= total_count && total_count > 0 && !hasInFlightReviews) { - let parsedMeta: Record = {}; - try { - parsedMeta = JSON.parse(convoy.metadata) as Record; - } catch { - /* ignore */ - } + const allBeadsDone = closed_count >= total_count && total_count > 0 && !hasInFlightReviews; - if (convoy.merge_mode === 'review-then-land' && convoy.feature_branch) { - if (!parsedMeta.ready_to_land) { - actions.push({ - type: 'set_convoy_ready_to_land', - convoy_id: convoy.bead_id, - }); - } + // Update progress if stale (skip if we're failing/closing the convoy this tick) + if (closed_count !== convoy.closed_beads) { + actions.push({ + type: 'update_convoy_progress', + convoy_id: convoy.bead_id, + closed_beads: closed_count, + }); + } - if (parsedMeta.ready_to_land) { - // Check if a landing MR already exists (any status) - const landingMrs = z - .object({ status: z.string() }) - .array() - .parse([ - ...query( - sql, - /* sql */ ` + if (!allBeadsDone) continue; + + if (convoy.merge_mode === 'review-then-land' && convoy.feature_branch) { + if (!parsedMeta.ready_to_land) { + actions.push({ + type: 'set_convoy_ready_to_land', + convoy_id: convoy.bead_id, + }); + } + + if (parsedMeta.ready_to_land) { + // Check if a landing MR already exists (any status) + const landingMrs = z + .object({ status: z.string() }) + .array() + .parse([ + ...query( + sql, + /* sql */ ` SELECT mr.${beads.columns.status} FROM ${bead_dependencies} bd INNER JOIN ${beads} mr ON mr.${beads.columns.bead_id} = bd.${bead_dependencies.columns.bead_id} @@ -1791,36 +1810,87 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { AND bd.${bead_dependencies.columns.dependency_type} = 'tracks' AND mr.${beads.columns.type} = 'merge_request' `, - [convoy.bead_id] - ), - ]); + [convoy.bead_id] + ), + ]); - // If a landing MR was already merged (closed), close the convoy - const hasMergedLanding = landingMrs.some(mr => mr.status === 'closed'); - if (hasMergedLanding) { - actions.push({ - type: 'close_convoy', - convoy_id: convoy.bead_id, - }); - continue; - } + // If a landing MR was already merged (closed), close the convoy + const hasMergedLanding = landingMrs.some(mr => mr.status === 'closed'); + if (hasMergedLanding) { + actions.push({ + type: 'close_convoy', + convoy_id: convoy.bead_id, + }); + continue; + } + + // Fix 1 (#2260): If a landing MR is active (open or in_progress), wait — don't create another + const hasActiveLanding = landingMrs.some( + mr => mr.status === 'open' || mr.status === 'in_progress' + ); + if (hasActiveLanding) continue; + + // Fix 2 (#2260): If max landing MR attempts exceeded and no landing MR is + // active or merged, fail the convoy. Checked after landing MR status lookup + // so the final allowed attempt can still succeed. + if (landingMrAttempts >= MAX_LANDING_MR_ATTEMPTS) { + actions.push({ + type: 'fail_convoy', + convoy_id: convoy.bead_id, + reason: `Landing MR creation failed after ${MAX_LANDING_MR_ATTEMPTS} attempts`, + }); + continue; + } + + // Fix 2 (#2260): Apply exponential cooldown between landing MR attempts + if (landingMrAttempts > 0 && lastLandingMrAttemptAt) { + const elapsed = Date.now() - new Date(lastLandingMrAttemptAt).getTime(); + const cooldownMs = Math.min( + Math.pow(2, landingMrAttempts) * LANDING_MR_COOLDOWN_BASE_MS, + LANDING_MR_COOLDOWN_MAX_MS + ); + if (elapsed < cooldownMs) continue; + } + + // Fix 3 (#2260): Check that tracked beads have at least one MR with a PR URL + const convoyBeadsWithPr = z + .object({ cnt: z.number() }) + .array() + .parse([ + ...query( + sql, + /* sql */ ` + SELECT count(*) as cnt + FROM ${bead_dependencies} track_dep + INNER JOIN ${bead_dependencies} mr_dep + ON mr_dep.${bead_dependencies.columns.depends_on_bead_id} = track_dep.${bead_dependencies.columns.bead_id} + INNER JOIN ${review_metadata} rm + ON rm.${review_metadata.columns.bead_id} = mr_dep.${bead_dependencies.columns.bead_id} + WHERE track_dep.${bead_dependencies.columns.depends_on_bead_id} = ? + AND track_dep.${bead_dependencies.columns.dependency_type} = 'tracks' + AND mr_dep.${bead_dependencies.columns.dependency_type} = 'tracks' + AND rm.${review_metadata.columns.pr_url} IS NOT NULL + `, + [convoy.bead_id] + ), + ]); - // If a landing MR is active (open or in_progress), wait for it - const hasActiveLanding = landingMrs.some( - mr => mr.status === 'open' || mr.status === 'in_progress' + if ((convoyBeadsWithPr[0]?.cnt ?? 0) === 0) { + console.warn( + `${LOG} convoy ${convoy.bead_id} has no beads with pr_url — skipping create_landing_mr` ); - if (hasActiveLanding) continue; - - // No landing MR exists yet — create one - { - // Need rig_id from one of the tracked beads - const rigRows = z - .object({ rig_id: z.string() }) - .array() - .parse([ - ...query( - sql, - /* sql */ ` + continue; + } + + // No landing MR exists yet and cooldown has passed — create one + { + const rigRows = z + .object({ rig_id: z.string() }) + .array() + .parse([ + ...query( + sql, + /* sql */ ` SELECT DISTINCT tracked.${beads.columns.rig_id} as rig_id FROM ${bead_dependencies} bd INNER JOIN ${beads} tracked ON tracked.${beads.columns.bead_id} = bd.${bead_dependencies.columns.bead_id} @@ -1829,29 +1899,28 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { AND tracked.${beads.columns.rig_id} IS NOT NULL LIMIT 1 `, - [convoy.bead_id] - ), - ]); - - if (rigRows.length > 0) { - const rig = getRig(sql, rigRows[0].rig_id); - actions.push({ - type: 'create_landing_mr', - convoy_id: convoy.bead_id, - rig_id: rigRows[0].rig_id, - feature_branch: convoy.feature_branch, - target_branch: rig?.default_branch ?? 'main', - }); - } + [convoy.bead_id] + ), + ]); + + if (rigRows.length > 0) { + const rig = getRig(sql, rigRows[0].rig_id); + actions.push({ + type: 'create_landing_mr', + convoy_id: convoy.bead_id, + rig_id: rigRows[0].rig_id, + feature_branch: convoy.feature_branch, + target_branch: rig?.default_branch ?? 'main', + }); } } - } else { - // review-and-merge or no feature branch — auto-close - actions.push({ - type: 'close_convoy', - convoy_id: convoy.bead_id, - }); } + } else { + // review-and-merge or no feature branch — auto-close + actions.push({ + type: 'close_convoy', + convoy_id: convoy.bead_id, + }); } } From d92064e30a042c5adc8aa720f5786f9838d87de2 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Tue, 14 Apr 2026 18:40:47 +0100 Subject: [PATCH 10/33] fix(gastown): prevent deleteAgent from reopening terminal beads; bump max_instances to 800 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deleteAgent() ran a blanket UPDATE SET status='open' on all beads assigned to the deleted agent, bypassing the terminal-state guard in updateBeadStatus(). On town boot, reconcileGC() deletes stale agents, which silently reopened closed/failed beads — causing wasted re-processing and token spend. Split into two queries: terminal beads only clear their assignee, non-terminal beads are reopened for re-dispatch as before. Also bumps container max_instances 700 → 800 and updates the image ref. --- services/gastown/src/dos/town/agents.ts | 17 ++++++++++++++++- services/gastown/wrangler.jsonc | 4 ++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/services/gastown/src/dos/town/agents.ts b/services/gastown/src/dos/town/agents.ts index 48d963f205..8b6c2765dc 100644 --- a/services/gastown/src/dos/town/agents.ts +++ b/services/gastown/src/dos/town/agents.ts @@ -202,7 +202,21 @@ export function updateAgentStatus(sql: SqlStorage, agentId: string, status: stri } export function deleteAgent(sql: SqlStorage, agentId: string): void { - // Unassign beads that reference this agent + // Clear assignee on terminal beads (closed/failed) without reopening them. + query( + sql, + /* sql */ ` + UPDATE ${beads} + SET ${beads.columns.assignee_agent_bead_id} = NULL, + ${beads.columns.updated_at} = ? + WHERE ${beads.assignee_agent_bead_id} = ? + AND ${beads.columns.status} IN ('closed', 'failed') + `, + [now(), agentId] + ); + + // Reopen non-terminal beads assigned to this agent so the reconciler + // can re-dispatch them. query( sql, /* sql */ ` @@ -211,6 +225,7 @@ export function deleteAgent(sql: SqlStorage, agentId: string): void { ${beads.columns.status} = 'open', ${beads.columns.updated_at} = ? WHERE ${beads.assignee_agent_bead_id} = ? + AND ${beads.columns.status} NOT IN ('closed', 'failed') `, [now(), agentId] ); diff --git a/services/gastown/wrangler.jsonc b/services/gastown/wrangler.jsonc index b3fc1fac2e..496894cd15 100644 --- a/services/gastown/wrangler.jsonc +++ b/services/gastown/wrangler.jsonc @@ -35,9 +35,9 @@ "containers": [ { "class_name": "TownContainerDO", - "image": "./container/Dockerfile", + "image": "registry.cloudflare.com/e115e769bcdd4c3d66af59d3332cb394/gastown-towncontainerdo:197958b7", "instance_type": "standard-4", - "max_instances": 700, + "max_instances": 800, }, ], From 41141f961c8ad19dba34bb7eb0f7eb5a33cdc8bf Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 15 Apr 2026 13:32:16 +0100 Subject: [PATCH 11/33] chore(gastown): bump max_instances to 810 --- services/gastown/wrangler.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/gastown/wrangler.jsonc b/services/gastown/wrangler.jsonc index 496894cd15..649919cee1 100644 --- a/services/gastown/wrangler.jsonc +++ b/services/gastown/wrangler.jsonc @@ -37,7 +37,7 @@ "class_name": "TownContainerDO", "image": "registry.cloudflare.com/e115e769bcdd4c3d66af59d3332cb394/gastown-towncontainerdo:197958b7", "instance_type": "standard-4", - "max_instances": 800, + "max_instances": 810, }, ], From 999a895ed80741442fa66e0c3e8c40eb562aff7c Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 15 Apr 2026 18:45:10 +0100 Subject: [PATCH 12/33] chore(gastown): update @kilocode/sdk and @kilocode/plugin to 7.2.7 RC Upgrade from 7.1.23 to 7.2.7 RC to address agent hanging issues. --- services/gastown/container/package.json | 4 ++-- services/gastown/container/plugin/package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/services/gastown/container/package.json b/services/gastown/container/package.json index 493808087a..dfb8424531 100644 --- a/services/gastown/container/package.json +++ b/services/gastown/container/package.json @@ -12,8 +12,8 @@ "lint": "pnpm -w exec oxlint --config .oxlintrc.json services/gastown/container/src" }, "dependencies": { - "@kilocode/plugin": "7.1.23", - "@kilocode/sdk": "7.1.23", + "@kilocode/plugin": "7.2.7", + "@kilocode/sdk": "7.2.7", "hono": "catalog:", "zod": "catalog:" }, diff --git a/services/gastown/container/plugin/package.json b/services/gastown/container/plugin/package.json index 899ebb7615..9c404d57a1 100644 --- a/services/gastown/container/plugin/package.json +++ b/services/gastown/container/plugin/package.json @@ -6,8 +6,8 @@ "description": "Kilo plugin exposing Gastown tools to agents", "main": "index.ts", "dependencies": { - "@kilocode/plugin": "7.1.23", - "@kilocode/sdk": "7.1.23", + "@kilocode/plugin": "7.2.7", + "@kilocode/sdk": "7.2.7", "zod": "^4.3.5" } } From e60ab86c674a69efc0587eff684a1c8c42562f38 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 15 Apr 2026 18:48:21 +0100 Subject: [PATCH 13/33] chore: update pnpm-lock.yaml for @kilocode/sdk 7.2.7 --- pnpm-lock.yaml | 688 +++------- .../db/tables/wasteland-config.table.d.ts | 28 + .../wasteland-connected-towns.table.d.ts | 13 + .../tables/wasteland-credentials.table.d.ts | 23 + .../db/tables/wasteland-members.table.d.ts | 19 + .../db/tables/wasteland-registry.table.d.ts | 18 + .../tables/wasteland-wanted-board.table.d.ts | 34 + .../dist-types/dos/Wasteland.do.d.ts | 95 ++ .../dist-types/dos/WastelandContainer.do.d.ts | 34 + .../dist-types/dos/WastelandRegistry.do.d.ts | 34 + .../dist-types/dos/wasteland/config.d.ts | 31 + .../dist-types/dos/wasteland/credentials.d.ts | 22 + .../dist-types/dos/wasteland/members.d.ts | 16 + .../dist-types/dos/wasteland/towns.d.ts | 10 + .../dos/wasteland/wanted-board.d.ts | 15 + .../dist-types/inbox/inbox-classifier.d.ts | 85 ++ .../middleware/analytics.middleware.d.ts | 18 + .../middleware/auth.middleware.d.ts | 11 + .../middleware/kilo-auth.middleware.d.ts | 9 + services/wasteland/dist-types/trpc/init.d.ts | 40 + .../wasteland/dist-types/trpc/ownership.d.ts | 12 + .../wasteland/dist-types/trpc/router.d.ts | 1179 +++++++++++++++++ .../wasteland/dist-types/trpc/schemas.d.ts | 473 +++++++ .../dist-types/util/analytics.util.d.ts | 25 + .../dist-types/util/billing.util.d.ts | 29 + .../dist-types/util/crypto.util.d.ts | 6 + .../dist-types/util/dolthub-api.util.d.ts | 99 ++ .../wasteland/dist-types/util/log.util.d.ts | 25 + .../wasteland/dist-types/util/query.util.d.ts | 13 + .../dist-types/util/rate-limit.util.d.ts | 17 + .../wasteland/dist-types/util/res.util.d.ts | 1 + .../dist-types/util/secret.util.d.ts | 9 + services/wasteland/dist-types/util/table.d.ts | 28 + .../wanted-board/wanted-board-ops.d.ts | 84 ++ .../dist-types/wasteland-rpc.entrypoint.d.ts | 93 ++ .../dist-types/wasteland.worker.d.ts | 11 + 36 files changed, 2878 insertions(+), 469 deletions(-) create mode 100644 services/wasteland/dist-types/db/tables/wasteland-config.table.d.ts create mode 100644 services/wasteland/dist-types/db/tables/wasteland-connected-towns.table.d.ts create mode 100644 services/wasteland/dist-types/db/tables/wasteland-credentials.table.d.ts create mode 100644 services/wasteland/dist-types/db/tables/wasteland-members.table.d.ts create mode 100644 services/wasteland/dist-types/db/tables/wasteland-registry.table.d.ts create mode 100644 services/wasteland/dist-types/db/tables/wasteland-wanted-board.table.d.ts create mode 100644 services/wasteland/dist-types/dos/Wasteland.do.d.ts create mode 100644 services/wasteland/dist-types/dos/WastelandContainer.do.d.ts create mode 100644 services/wasteland/dist-types/dos/WastelandRegistry.do.d.ts create mode 100644 services/wasteland/dist-types/dos/wasteland/config.d.ts create mode 100644 services/wasteland/dist-types/dos/wasteland/credentials.d.ts create mode 100644 services/wasteland/dist-types/dos/wasteland/members.d.ts create mode 100644 services/wasteland/dist-types/dos/wasteland/towns.d.ts create mode 100644 services/wasteland/dist-types/dos/wasteland/wanted-board.d.ts create mode 100644 services/wasteland/dist-types/inbox/inbox-classifier.d.ts create mode 100644 services/wasteland/dist-types/middleware/analytics.middleware.d.ts create mode 100644 services/wasteland/dist-types/middleware/auth.middleware.d.ts create mode 100644 services/wasteland/dist-types/middleware/kilo-auth.middleware.d.ts create mode 100644 services/wasteland/dist-types/trpc/init.d.ts create mode 100644 services/wasteland/dist-types/trpc/ownership.d.ts create mode 100644 services/wasteland/dist-types/trpc/router.d.ts create mode 100644 services/wasteland/dist-types/trpc/schemas.d.ts create mode 100644 services/wasteland/dist-types/util/analytics.util.d.ts create mode 100644 services/wasteland/dist-types/util/billing.util.d.ts create mode 100644 services/wasteland/dist-types/util/crypto.util.d.ts create mode 100644 services/wasteland/dist-types/util/dolthub-api.util.d.ts create mode 100644 services/wasteland/dist-types/util/log.util.d.ts create mode 100644 services/wasteland/dist-types/util/query.util.d.ts create mode 100644 services/wasteland/dist-types/util/rate-limit.util.d.ts create mode 100644 services/wasteland/dist-types/util/res.util.d.ts create mode 100644 services/wasteland/dist-types/util/secret.util.d.ts create mode 100644 services/wasteland/dist-types/util/table.d.ts create mode 100644 services/wasteland/dist-types/wanted-board/wanted-board-ops.d.ts create mode 100644 services/wasteland/dist-types/wasteland-rpc.entrypoint.d.ts create mode 100644 services/wasteland/dist-types/wasteland.worker.d.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6af4d55e1a..43918aad37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -366,25 +366,25 @@ importers: version: 3.14.0(@faker-js/faker@10.3.0)(zod@4.3.6) '@chromatic-com/storybook': specifier: ^4.1.3 - version: 4.1.3(@chromatic-com/playwright@0.12.8(@playwright/test@1.58.2)(@types/react@19.2.14)(esbuild@0.27.4)(typescript@5.9.3)(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + version: 4.1.3(@chromatic-com/playwright@0.12.8(@playwright/test@1.58.2)(@types/react@19.2.14)(esbuild@0.27.4)(typescript@5.9.3)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) '@faker-js/faker': specifier: ^10.3.0 version: 10.3.0 '@storybook/addon-docs': specifier: ^9.1.20 - version: 9.1.20(@types/react@19.2.14)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + version: 9.1.20(@types/react@19.2.14)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) '@storybook/addon-links': specifier: ^9.1.20 - version: 9.1.20(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + version: 9.1.20(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) '@storybook/addon-themes': specifier: ^9.1.20 - version: 9.1.20(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + version: 9.1.20(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) '@storybook/nextjs': specifier: ^9.1.20 - version: 9.1.20(patch_hash=e1857649664eed8f87877c352d277c90d4af5a58d0ad931105f033c8c08165c1)(esbuild@0.27.4)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(type-fest@4.41.0)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(webpack@5.105.4(esbuild@0.27.4)) + version: 9.1.20(patch_hash=e1857649664eed8f87877c352d277c90d4af5a58d0ad931105f033c8c08165c1)(esbuild@0.27.4)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(type-fest@4.41.0)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(webpack@5.105.4(esbuild@0.27.4)) '@storybook/test-runner': specifier: ^0.23.0 - version: 0.23.0(@types/node@25.6.0)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + version: 0.23.0(@types/node@25.5.0)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.2.2) @@ -399,7 +399,7 @@ importers: version: 7.0.0-dev.20260319.1 chromatic: specifier: ^13.3.5 - version: 13.3.5(@chromatic-com/playwright@0.12.8(@playwright/test@1.58.2)(@types/react@19.2.14)(esbuild@0.27.4)(typescript@5.9.3)(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + version: 13.3.5(@chromatic-com/playwright@0.12.8(@playwright/test@1.58.2)(@types/react@19.2.14)(esbuild@0.27.4)(typescript@5.9.3)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) dotenv: specifier: ^17.3.1 version: 17.3.1 @@ -417,7 +417,7 @@ importers: version: 3.0.5 storybook: specifier: ^9.1.17 - version: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) tailwindcss: specifier: ^4.2.1 version: 4.2.2 @@ -669,7 +669,7 @@ importers: version: 14.25.1 drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.12)(pg@8.20.0) + version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.11)(pg@8.20.0) event-source-polyfill: specifier: ^1.0.31 version: 1.0.31 @@ -877,7 +877,7 @@ importers: dependencies: drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.12)(pg@8.20.0) + version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.11)(pg@8.20.0) pg: specifier: ^8.20.0 version: 8.20.0 @@ -933,7 +933,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) packages/trpc: dependencies: @@ -948,10 +948,10 @@ importers: version: 8.9.0 drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.12)(pg@8.20.0) + version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.11)(pg@8.20.0) stripe: specifier: 'catalog:' - version: 19.3.0(@types/node@25.6.0) + version: 19.3.0(@types/node@25.5.0) zod: specifier: 'catalog:' version: 4.3.6 @@ -964,7 +964,7 @@ importers: version: link:../encryption '@sentry/nextjs': specifier: ^10.43.0 - version: 10.43.0(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.5))(react@19.2.5))(react@19.2.5)(webpack@5.105.4) + version: 10.43.0(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.105.4) '@typescript/native-preview': specifier: 'catalog:' version: 7.0.0-dev.20260319.1 @@ -1004,7 +1004,7 @@ importers: version: 5.9.3 vitest: specifier: ~3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.6.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) services/ai-attribution: dependencies: @@ -1013,7 +1013,7 @@ importers: version: link:../../packages/worker-utils drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.12)(pg@8.20.0) + version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.11)(pg@8.20.0) hono: specifier: ^4.12.7 version: 4.12.8 @@ -1035,7 +1035,7 @@ importers: version: 0.31.9 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.6.0) + version: 29.7.0(@types/node@25.5.0) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1056,7 +1056,7 @@ importers: version: 8.0.3 drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.12)(pg@8.20.0) + version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.11)(pg@8.20.0) jsonwebtoken: specifier: 'catalog:' version: 9.0.3 @@ -1170,7 +1170,7 @@ importers: version: 11.13.0(typescript@5.9.3) drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.12)(pg@8.20.0) + version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.11)(pg@8.20.0) jsonwebtoken: specifier: 'catalog:' version: 9.0.3 @@ -1237,7 +1237,7 @@ importers: version: 11.13.0(typescript@5.9.3) drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.12)(pg@8.20.0) + version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.11)(pg@8.20.0) hono: specifier: ^4.12.7 version: 4.12.8 @@ -1293,7 +1293,7 @@ importers: devDependencies: '@types/bun': specifier: latest - version: 1.3.12 + version: 1.3.11 '@types/node': specifier: ^20.19.37 version: 20.19.37 @@ -1308,7 +1308,7 @@ importers: devDependencies: '@types/bun': specifier: latest - version: 1.3.12 + version: 1.3.11 '@types/node': specifier: ^20.19.37 version: 20.19.37 @@ -1471,7 +1471,7 @@ importers: version: 7.0.0-dev.20260319.1 jest: specifier: ^30.3.0 - version: 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) + version: 30.3.0(@types/node@24.12.0)(esbuild-register@3.6.0(esbuild@0.27.4)) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1501,7 +1501,7 @@ importers: version: 11.13.0(typescript@5.9.3) drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.12)(pg@8.20.0) + version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.11)(pg@8.20.0) hono: specifier: ^4.12.7 version: 4.12.8 @@ -1555,11 +1555,11 @@ importers: services/gastown/container: dependencies: '@kilocode/plugin': - specifier: 7.1.23 - version: 7.1.23 + specifier: 7.2.7 + version: 7.2.7 '@kilocode/sdk': - specifier: 7.1.23 - version: 7.1.23 + specifier: 7.2.7 + version: 7.2.7 hono: specifier: ^4.12.7 version: 4.12.8 @@ -1575,7 +1575,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.6.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) services/git-token-service: dependencies: @@ -1587,7 +1587,7 @@ importers: version: 8.2.0 drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.12)(pg@8.20.0) + version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.11)(pg@8.20.0) zod: specifier: 'catalog:' version: 4.3.6 @@ -1625,7 +1625,7 @@ importers: version: 5.9.3 vitest: specifier: ~3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.6.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) wrangler: specifier: 'catalog:' version: 4.73.0(@cloudflare/workers-types@4.20260313.1) @@ -1689,7 +1689,7 @@ importers: version: link:../../packages/worker-utils drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.12)(pg@8.20.0) + version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.11)(pg@8.20.0) hono: specifier: ^4.12.7 version: 4.12.8 @@ -1735,7 +1735,7 @@ importers: version: 4.1.0 drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.12)(pg@8.20.0) + version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.11)(pg@8.20.0) jose: specifier: 'catalog:' version: 6.2.1 @@ -1769,7 +1769,7 @@ importers: version: link:../../packages/db drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.12)(pg@8.20.0) + version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.11)(pg@8.20.0) node-html-markdown: specifier: ^2.0.0 version: 2.0.0 @@ -1785,7 +1785,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) wrangler: specifier: 'catalog:' version: 4.73.0(@cloudflare/workers-types@4.20260313.1) @@ -1800,7 +1800,7 @@ importers: version: link:../../packages/worker-utils drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.12)(pg@8.20.0) + version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.11)(pg@8.20.0) expo-server-sdk: specifier: ^6.1.0 version: 6.1.0(patch_hash=7850520582b5b394397b35d1ea195192fe78589d8a6a748fe15177b818c4ed0b) @@ -1843,7 +1843,7 @@ importers: version: link:../../packages/worker-utils drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.12)(pg@8.20.0) + version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.11)(pg@8.20.0) hono: specifier: ^4.12.7 version: 4.12.8 @@ -1880,7 +1880,7 @@ importers: version: link:../../packages/db drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.12)(pg@8.20.0) + version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.11)(pg@8.20.0) workers-tagged-logger: specifier: 'catalog:' version: 1.0.0 @@ -1911,7 +1911,7 @@ importers: version: link:../../packages/db drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.12)(pg@8.20.0) + version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.11)(pg@8.20.0) zod: specifier: 'catalog:' version: 4.3.6 @@ -1942,7 +1942,7 @@ importers: version: 0.0.22 drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.12)(pg@8.20.0) + version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.11)(pg@8.20.0) hono: specifier: ^4.12.7 version: 4.12.8 @@ -1994,7 +1994,7 @@ importers: version: 10.0.1 drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.12)(pg@8.20.0) + version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.11)(pg@8.20.0) hono: specifier: ^4.12.7 version: 4.12.8 @@ -4158,12 +4158,23 @@ packages: react: '*' react-native: '*' - '@kilocode/plugin@7.1.23': - resolution: {integrity: sha512-sFI0rlgQg3mzYP05/gg09IE1mN4QyAXyOlJjM36CFoVKi1bPYz/dqEqr6ddmMNmB577A4gDu0D07hr0pGIFrBA==} + '@kilocode/plugin@7.2.7': + resolution: {integrity: sha512-m4fHQlrUjuZTEABtOUIoHfUW3ejPpF8Jv2VhkFYVKJmuerltQBBFb4udrN97GJHMyoLL3nzJ6bNZkg3Czv8Z3g==} + peerDependencies: + '@opentui/core': '>=0.1.97' + '@opentui/solid': '>=0.1.97' + peerDependenciesMeta: + '@opentui/core': + optional: true + '@opentui/solid': + optional: true '@kilocode/sdk@7.1.23': resolution: {integrity: sha512-moSKXqpwE+ozVbNE1aYIUb5Kd7fesOicRUn90WiMlp+8lRhqQc6ZTTIaIB9ZzD7Dak//4bSuo++bb+Jtw3U4Fg==} + '@kilocode/sdk@7.2.7': + resolution: {integrity: sha512-710n8PQ3QfmTwEdzOW2p7ur79rF9IBJk6nGsUu1hp8o/66RTkpVYC0EB7DKNfJ1NzTWmUqmhJZGWq4suCfADKQ==} + '@lottiefiles/dotlottie-react@0.17.15': resolution: {integrity: sha512-4wYAjsJhM28eUvJ/gT3KRM6fcyT7EM9n7PDrP71LaBTacc6bSN43qFTSJc1Li3QxUiraz23p0Q8EJBzXo8DsRw==} peerDependencies: @@ -7291,9 +7302,6 @@ packages: '@types/bun@1.3.11': resolution: {integrity: sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg==} - '@types/bun@1.3.12': - resolution: {integrity: sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -7416,9 +7424,6 @@ packages: '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} - '@types/node@25.6.0': - resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} - '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -8454,9 +8459,6 @@ packages: bun-types@1.3.11: resolution: {integrity: sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==} - bun-types@1.3.12: - resolution: {integrity: sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA==} - bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -12934,10 +12936,6 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} - react@19.2.5: - resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} - engines: {node: '>=0.10.0'} - readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -14116,9 +14114,6 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - undici-types@7.19.2: - resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} - undici@6.24.1: resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==} engines: {node: '>=18.17'} @@ -16323,16 +16318,16 @@ snapshots: - vite - webpack-cli - '@chromatic-com/playwright@0.12.8(@playwright/test@1.58.2)(@types/react@19.2.14)(esbuild@0.27.4)(typescript@5.9.3)(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@chromatic-com/playwright@0.12.8(@playwright/test@1.58.2)(@types/react@19.2.14)(esbuild@0.27.4)(typescript@5.9.3)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@chromaui/rrweb-snapshot': 2.0.0-alpha.18-noAbsolute '@playwright/test': 1.58.2 '@segment/analytics-node': 2.1.3 - '@storybook/addon-essentials': 8.5.8(@types/react@19.2.14)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/addon-essentials': 8.5.8(@types/react@19.2.14)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) '@storybook/csf': 0.1.13 - '@storybook/manager-api': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) - '@storybook/server-webpack5': 8.5.8(esbuild@0.27.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3) - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@storybook/manager-api': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/server-webpack5': 8.5.8(esbuild@0.27.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) ts-dedent: 2.2.0 transitivePeerDependencies: - '@rspack/core' @@ -16352,13 +16347,13 @@ snapshots: - webpack-cli optional: true - '@chromatic-com/storybook@4.1.3(@chromatic-com/playwright@0.12.8(@playwright/test@1.58.2)(@types/react@19.2.14)(esbuild@0.27.4)(typescript@5.9.3)(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@chromatic-com/storybook@4.1.3(@chromatic-com/playwright@0.12.8(@playwright/test@1.58.2)(@types/react@19.2.14)(esbuild@0.27.4)(typescript@5.9.3)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: '@neoconfetti/react': 1.0.0 - chromatic: 13.3.5(@chromatic-com/playwright@0.12.8(@playwright/test@1.58.2)(@types/react@19.2.14)(esbuild@0.27.4)(typescript@5.9.3)(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + chromatic: 13.3.5(@chromatic-com/playwright@0.12.8(@playwright/test@1.58.2)(@types/react@19.2.14)(esbuild@0.27.4)(typescript@5.9.3)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) filesize: 10.1.6 jsonfile: 6.2.0 - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) strip-ansi: 7.2.0 transitivePeerDependencies: - '@chromatic-com/cypress' @@ -16457,7 +16452,7 @@ snapshots: devalue: 5.6.4 miniflare: 4.20250906.0 semver: 7.7.4 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.6.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) wrangler: 4.35.0(@cloudflare/workers-types@4.20260313.1) zod: 3.25.76 transitivePeerDependencies: @@ -17304,7 +17299,7 @@ snapshots: '@jest/console@30.3.0': dependencies: '@jest/types': 30.3.0 - '@types/node': 24.12.0 + '@types/node': 25.5.0 chalk: 4.1.2 jest-message-util: 30.3.0 jest-util: 30.3.0 @@ -17439,7 +17434,7 @@ snapshots: dependencies: '@jest/types': 30.3.0 '@sinonjs/fake-timers': 15.1.1 - '@types/node': 24.12.0 + '@types/node': 25.5.0 jest-message-util: 30.3.0 jest-mock: 30.3.0 jest-util: 30.3.0 @@ -17466,7 +17461,7 @@ snapshots: '@jest/pattern@30.0.1': dependencies: - '@types/node': 24.12.0 + '@types/node': 25.5.0 jest-regex-util: 30.0.1 '@jest/reporters@29.7.0': @@ -17506,7 +17501,7 @@ snapshots: '@jest/transform': 30.3.0 '@jest/types': 30.3.0 '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 24.12.0 + '@types/node': 25.5.0 chalk: 4.1.2 collect-v8-coverage: 1.0.3 exit-x: 0.2.2 @@ -17676,13 +17671,17 @@ snapshots: react: 19.2.0 react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0) - '@kilocode/plugin@7.1.23': + '@kilocode/plugin@7.2.7': dependencies: - '@kilocode/sdk': 7.1.23 + '@kilocode/sdk': 7.2.7 zod: 4.1.8 '@kilocode/sdk@7.1.23': {} + '@kilocode/sdk@7.2.7': + dependencies: + cross-spawn: 7.0.6 + '@lottiefiles/dotlottie-react@0.17.15(react@19.2.4)': dependencies: '@lottiefiles/dotlottie-web': 0.63.0 @@ -19969,7 +19968,7 @@ snapshots: - supports-color - webpack - '@sentry/nextjs@10.43.0(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.5))(react@19.2.5))(react@19.2.5)(webpack@5.105.4)': + '@sentry/nextjs@10.43.0(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.105.4)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.40.0 @@ -19979,10 +19978,10 @@ snapshots: '@sentry/core': 10.43.0 '@sentry/node': 10.43.0 '@sentry/opentelemetry': 10.43.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.40.0) - '@sentry/react': 10.43.0(react@19.2.5) + '@sentry/react': 10.43.0(react@19.2.4) '@sentry/vercel-edge': 10.43.0 '@sentry/webpack-plugin': 5.1.1(webpack@5.105.4) - next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) rollup: 4.59.0 stacktrace-parser: 0.1.11 transitivePeerDependencies: @@ -20081,12 +20080,6 @@ snapshots: '@sentry/core': 10.43.0 react: 19.2.4 - '@sentry/react@10.43.0(react@19.2.5)': - dependencies: - '@sentry/browser': 10.43.0 - '@sentry/core': 10.43.0 - react: 19.2.5 - '@sentry/types@10.37.0': dependencies: '@sentry/core': 10.37.0 @@ -20517,13 +20510,13 @@ snapshots: '@standard-schema/utils@0.3.0': {} - '@storybook/addon-actions@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/addon-actions@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: '@storybook/global': 5.0.0 '@types/uuid': 9.0.8 dequal: 2.0.3 polished: 4.3.1 - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) uuid: 9.0.1 optional: true @@ -20536,11 +20529,11 @@ snapshots: storybook: 9.1.20 uuid: 9.0.1 - '@storybook/addon-backgrounds@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/addon-backgrounds@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: '@storybook/global': 5.0.0 memoizerific: 1.11.3 - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) ts-dedent: 2.2.0 optional: true @@ -20551,11 +20544,11 @@ snapshots: storybook: 9.1.20 ts-dedent: 2.2.0 - '@storybook/addon-controls@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/addon-controls@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: '@storybook/global': 5.0.0 dequal: 2.0.3 - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) ts-dedent: 2.2.0 optional: true @@ -20566,15 +20559,15 @@ snapshots: storybook: 9.1.20 ts-dedent: 2.2.0 - '@storybook/addon-docs@8.5.8(@types/react@19.2.14)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/addon-docs@8.5.8(@types/react@19.2.14)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.4) - '@storybook/blocks': 8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) - '@storybook/csf-plugin': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) - '@storybook/react-dom-shim': 8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/blocks': 8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/csf-plugin': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/react-dom-shim': 8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' @@ -20593,31 +20586,31 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@storybook/addon-docs@9.1.20(@types/react@19.2.14)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/addon-docs@9.1.20(@types/react@19.2.14)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.4) - '@storybook/csf-plugin': 9.1.20(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/csf-plugin': 9.1.20(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) '@storybook/icons': 1.6.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@storybook/react-dom-shim': 9.1.20(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/react-dom-shim': 9.1.20(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' - '@storybook/addon-essentials@8.5.8(@types/react@19.2.14)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': - dependencies: - '@storybook/addon-actions': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) - '@storybook/addon-backgrounds': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) - '@storybook/addon-controls': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) - '@storybook/addon-docs': 8.5.8(@types/react@19.2.14)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) - '@storybook/addon-highlight': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) - '@storybook/addon-measure': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) - '@storybook/addon-outline': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) - '@storybook/addon-toolbars': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) - '@storybook/addon-viewport': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@storybook/addon-essentials@8.5.8(@types/react@19.2.14)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + dependencies: + '@storybook/addon-actions': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/addon-backgrounds': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/addon-controls': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/addon-docs': 8.5.8(@types/react@19.2.14)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/addon-highlight': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/addon-measure': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/addon-outline': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/addon-toolbars': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/addon-viewport': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' @@ -20639,10 +20632,10 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@storybook/addon-highlight@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/addon-highlight@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: '@storybook/global': 5.0.0 - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) optional: true '@storybook/addon-highlight@8.5.8(storybook@9.1.20)': @@ -20650,17 +20643,17 @@ snapshots: '@storybook/global': 5.0.0 storybook: 9.1.20 - '@storybook/addon-links@9.1.20(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/addon-links@9.1.20(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: '@storybook/global': 5.0.0 - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) optionalDependencies: react: 19.2.4 - '@storybook/addon-measure@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/addon-measure@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: '@storybook/global': 5.0.0 - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) tiny-invariant: 1.3.3 optional: true @@ -20670,10 +20663,10 @@ snapshots: storybook: 9.1.20 tiny-invariant: 1.3.3 - '@storybook/addon-outline@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/addon-outline@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: '@storybook/global': 5.0.0 - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) ts-dedent: 2.2.0 optional: true @@ -20683,24 +20676,24 @@ snapshots: storybook: 9.1.20 ts-dedent: 2.2.0 - '@storybook/addon-themes@9.1.20(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/addon-themes@9.1.20(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) ts-dedent: 2.2.0 - '@storybook/addon-toolbars@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/addon-toolbars@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) optional: true '@storybook/addon-toolbars@8.5.8(storybook@9.1.20)': dependencies: storybook: 9.1.20 - '@storybook/addon-viewport@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/addon-viewport@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: memoizerific: 1.11.3 - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) optional: true '@storybook/addon-viewport@8.5.8(storybook@9.1.20)': @@ -20708,11 +20701,11 @@ snapshots: memoizerific: 1.11.3 storybook: 9.1.20 - '@storybook/blocks@8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/blocks@8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: '@storybook/csf': 0.1.12 '@storybook/icons': 1.6.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) ts-dedent: 2.2.0 optionalDependencies: react: 19.2.4 @@ -20765,9 +20758,9 @@ snapshots: - uglify-js - webpack-cli - '@storybook/builder-webpack5@8.5.8(esbuild@0.27.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3)': + '@storybook/builder-webpack5@8.5.8(esbuild@0.27.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3)': dependencies: - '@storybook/core-webpack': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/core-webpack': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) '@types/semver': 7.7.1 browser-assert: 1.2.1 case-sensitive-paths-webpack-plugin: 2.4.0 @@ -20781,7 +20774,7 @@ snapshots: path-browserify: 1.0.1 process: 0.11.10 semver: 7.7.4 - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) style-loader: 3.3.4(webpack@5.105.4(esbuild@0.27.4)) terser-webpack-plugin: 5.4.0(esbuild@0.27.4)(webpack@5.105.4(esbuild@0.27.4)) ts-dedent: 2.2.0 @@ -20802,9 +20795,9 @@ snapshots: - webpack-cli optional: true - '@storybook/builder-webpack5@9.1.20(esbuild@0.27.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3)': + '@storybook/builder-webpack5@9.1.20(esbuild@0.27.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3)': dependencies: - '@storybook/core-webpack': 9.1.20(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/core-webpack': 9.1.20(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) case-sensitive-paths-webpack-plugin: 2.4.0 cjs-module-lexer: 1.4.3 css-loader: 6.11.0(webpack@5.105.4(esbuild@0.27.4)) @@ -20812,7 +20805,7 @@ snapshots: fork-ts-checker-webpack-plugin: 8.0.0(typescript@5.9.3)(webpack@5.105.4(esbuild@0.27.4)) html-webpack-plugin: 5.6.6(webpack@5.105.4(esbuild@0.27.4)) magic-string: 0.30.21 - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) style-loader: 3.3.4(webpack@5.105.4(esbuild@0.27.4)) terser-webpack-plugin: 5.4.0(esbuild@0.27.4)(webpack@5.105.4(esbuild@0.27.4)) ts-dedent: 2.2.0 @@ -20829,18 +20822,18 @@ snapshots: - uglify-js - webpack-cli - '@storybook/components@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/components@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) optional: true '@storybook/components@8.5.8(storybook@9.1.20)': dependencies: storybook: 9.1.20 - '@storybook/core-webpack@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/core-webpack@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) ts-dedent: 2.2.0 optional: true @@ -20849,14 +20842,14 @@ snapshots: storybook: 9.1.20 ts-dedent: 2.2.0 - '@storybook/core-webpack@9.1.20(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/core-webpack@9.1.20(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) ts-dedent: 2.2.0 - '@storybook/csf-plugin@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/csf-plugin@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) unplugin: 1.16.1 optional: true @@ -20865,9 +20858,9 @@ snapshots: storybook: 9.1.20 unplugin: 1.16.1 - '@storybook/csf-plugin@9.1.20(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/csf-plugin@9.1.20(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) unplugin: 1.16.1 '@storybook/csf@0.1.12': @@ -20885,16 +20878,16 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@storybook/manager-api@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/manager-api@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) optional: true '@storybook/manager-api@8.5.8(storybook@9.1.20)': dependencies: storybook: 9.1.20 - '@storybook/nextjs@9.1.20(patch_hash=e1857649664eed8f87877c352d277c90d4af5a58d0ad931105f033c8c08165c1)(esbuild@0.27.4)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(type-fest@4.41.0)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(webpack@5.105.4(esbuild@0.27.4))': + '@storybook/nextjs@9.1.20(patch_hash=e1857649664eed8f87877c352d277c90d4af5a58d0ad931105f033c8c08165c1)(esbuild@0.27.4)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(type-fest@4.41.0)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(webpack@5.105.4(esbuild@0.27.4))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) @@ -20910,9 +20903,9 @@ snapshots: '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) '@babel/runtime': 7.29.2 '@pmmmwh/react-refresh-webpack-plugin': 0.5.17(react-refresh@0.14.2)(type-fest@4.41.0)(webpack-hot-middleware@2.26.1)(webpack@5.105.4(esbuild@0.27.4)) - '@storybook/builder-webpack5': 9.1.20(esbuild@0.27.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3) - '@storybook/preset-react-webpack': 9.1.20(esbuild@0.27.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3) - '@storybook/react': 9.1.20(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3) + '@storybook/builder-webpack5': 9.1.20(esbuild@0.27.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3) + '@storybook/preset-react-webpack': 9.1.20(esbuild@0.27.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3) + '@storybook/react': 9.1.20(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3) '@types/semver': 7.7.1 babel-loader: 9.2.1(@babel/core@7.29.0)(webpack@5.105.4(esbuild@0.27.4)) css-loader: 6.11.0(webpack@5.105.4(esbuild@0.27.4)) @@ -20928,7 +20921,7 @@ snapshots: resolve-url-loader: 5.0.0 sass-loader: 16.0.7(webpack@5.105.4(esbuild@0.27.4)) semver: 7.7.4 - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) style-loader: 3.3.4(webpack@5.105.4(esbuild@0.27.4)) styled-jsx: 5.1.7(@babel/core@7.29.0)(react@19.2.4) tsconfig-paths: 4.2.0 @@ -20954,9 +20947,9 @@ snapshots: - webpack-hot-middleware - webpack-plugin-serve - '@storybook/preset-react-webpack@9.1.20(esbuild@0.27.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3)': + '@storybook/preset-react-webpack@9.1.20(esbuild@0.27.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3)': dependencies: - '@storybook/core-webpack': 9.1.20(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/core-webpack': 9.1.20(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) '@storybook/react-docgen-typescript-plugin': 1.0.6--canary.9.0c3f3b7.0(typescript@5.9.3)(webpack@5.105.4(esbuild@0.27.4)) '@types/semver': 7.7.1 find-up: 7.0.0 @@ -20966,7 +20959,7 @@ snapshots: react-dom: 19.2.4(react@19.2.4) resolve: 1.22.11 semver: 7.7.4 - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) tsconfig-paths: 4.2.0 webpack: 5.105.4(esbuild@0.27.4) optionalDependencies: @@ -20978,13 +20971,13 @@ snapshots: - uglify-js - webpack-cli - '@storybook/preset-server-webpack@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/preset-server-webpack@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - '@storybook/core-webpack': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/core-webpack': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) '@storybook/global': 5.0.0 - '@storybook/server': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/server': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) safe-identifier: 0.4.2 - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) ts-dedent: 2.2.0 yaml-loader: 0.8.1 optional: true @@ -20999,9 +20992,9 @@ snapshots: ts-dedent: 2.2.0 yaml-loader: 0.8.1 - '@storybook/preview-api@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/preview-api@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) optional: true '@storybook/preview-api@8.5.8(storybook@9.1.20)': @@ -21022,11 +21015,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@storybook/react-dom-shim@8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/react-dom-shim@8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) optional: true '@storybook/react-dom-shim@8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20)': @@ -21035,19 +21028,19 @@ snapshots: react-dom: 19.2.4(react@19.2.4) storybook: 9.1.20 - '@storybook/react-dom-shim@9.1.20(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/react-dom-shim@9.1.20(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) - '@storybook/react@9.1.20(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3)': + '@storybook/react@9.1.20(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 9.1.20(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/react-dom-shim': 9.1.20(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) optionalDependencies: typescript: 5.9.3 @@ -21065,12 +21058,12 @@ snapshots: - uglify-js - webpack-cli - '@storybook/server-webpack5@8.5.8(esbuild@0.27.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3)': + '@storybook/server-webpack5@8.5.8(esbuild@0.27.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3)': dependencies: - '@storybook/builder-webpack5': 8.5.8(esbuild@0.27.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3) - '@storybook/preset-server-webpack': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) - '@storybook/server': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@storybook/builder-webpack5': 8.5.8(esbuild@0.27.4)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3) + '@storybook/preset-server-webpack': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/server': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) transitivePeerDependencies: - '@rspack/core' - '@swc/core' @@ -21080,15 +21073,15 @@ snapshots: - webpack-cli optional: true - '@storybook/server@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/server@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - '@storybook/components': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/components': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) '@storybook/csf': 0.1.12 '@storybook/global': 5.0.0 - '@storybook/manager-api': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) - '@storybook/preview-api': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) - '@storybook/theming': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@storybook/manager-api': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/preview-api': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@storybook/theming': 8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) ts-dedent: 2.2.0 yaml: 2.8.2 optional: true @@ -21105,7 +21098,7 @@ snapshots: ts-dedent: 2.2.0 yaml: 2.8.2 - '@storybook/test-runner@0.23.0(@types/node@25.6.0)(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/test-runner@0.23.0(@types/node@25.5.0)(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: '@babel/core': 7.29.0 '@babel/generator': 7.29.1 @@ -21115,17 +21108,17 @@ snapshots: '@swc/core': 1.15.18 '@swc/jest': 0.2.39(@swc/core@1.15.18) expect-playwright: 0.8.0 - jest: 29.7.0(@types/node@25.6.0) + jest: 29.7.0(@types/node@25.5.0) jest-circus: 29.7.0 jest-environment-node: 29.7.0 jest-junit: 16.0.0 - jest-playwright-preset: 4.0.0(jest-circus@29.7.0)(jest-environment-node@29.7.0)(jest-runner@29.7.0)(jest@29.7.0(@types/node@25.6.0)) + jest-playwright-preset: 4.0.0(jest-circus@29.7.0)(jest-environment-node@29.7.0)(jest-runner@29.7.0)(jest@29.7.0(@types/node@25.5.0)) jest-runner: 29.7.0 jest-serializer-html: 7.1.0 - jest-watch-typeahead: 2.2.2(jest@29.7.0(@types/node@25.6.0)) + jest-watch-typeahead: 2.2.2(jest@29.7.0(@types/node@25.5.0)) nyc: 15.1.0 playwright: 1.58.2 - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) transitivePeerDependencies: - '@swc/helpers' - '@types/node' @@ -21135,9 +21128,9 @@ snapshots: - supports-color - ts-node - '@storybook/theming@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + '@storybook/theming@8.5.8(storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - storybook: 9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + storybook: 9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) optional: true '@storybook/theming@8.5.8(storybook@9.1.20)': @@ -21391,10 +21384,6 @@ snapshots: dependencies: bun-types: 1.3.11 - '@types/bun@1.3.12': - dependencies: - bun-types: 1.3.12 - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -21402,7 +21391,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 24.12.0 + '@types/node': 25.5.0 '@types/d3-array@3.2.2': {} @@ -21512,7 +21501,7 @@ snapshots: '@types/mysql@2.15.27': dependencies: - '@types/node': 24.12.0 + '@types/node': 25.5.0 '@types/node@20.19.37': dependencies: @@ -21526,10 +21515,6 @@ snapshots: dependencies: undici-types: 7.18.2 - '@types/node@25.6.0': - dependencies: - undici-types: 7.19.2 - '@types/parse-json@4.0.2': {} '@types/pg-pool@2.0.7': @@ -21538,7 +21523,7 @@ snapshots: '@types/pg@8.15.6': dependencies: - '@types/node': 24.12.0 + '@types/node': 25.5.0 pg-protocol: 1.13.0 pg-types: 2.2.0 @@ -21574,7 +21559,7 @@ snapshots: '@types/tedious@4.0.14': dependencies: - '@types/node': 24.12.0 + '@types/node': 25.5.0 '@types/trusted-types@2.0.7': optional: true @@ -21589,7 +21574,7 @@ snapshots: '@types/wait-on@5.3.4': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.5.0 '@types/ws@8.18.1': dependencies: @@ -21817,12 +21802,6 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@3.2.4': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - '@vitest/mocker@3.2.4(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 @@ -21839,14 +21818,6 @@ snapshots: optionalDependencies: vite: 8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@3.2.4(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@4.1.0(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.1.0 @@ -21855,13 +21826,13 @@ snapshots: optionalDependencies: vite: 8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@4.1.0(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.1.0(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.1.0 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@3.2.4': dependencies: @@ -22684,10 +22655,6 @@ snapshots: dependencies: '@types/node': 25.5.0 - bun-types@1.3.12: - dependencies: - '@types/node': 20.19.37 - bytes@3.1.2: {} cac@6.7.14: {} @@ -22794,13 +22761,13 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - chromatic@13.3.5(@chromatic-com/playwright@0.12.8(@playwright/test@1.58.2)(@types/react@19.2.14)(esbuild@0.27.4)(typescript@5.9.3)(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))): + chromatic@13.3.5(@chromatic-com/playwright@0.12.8(@playwright/test@1.58.2)(@types/react@19.2.14)(esbuild@0.27.4)(typescript@5.9.3)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))): optionalDependencies: - '@chromatic-com/playwright': 0.12.8(@playwright/test@1.58.2)(@types/react@19.2.14)(esbuild@0.27.4)(typescript@5.9.3)(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@chromatic-com/playwright': 0.12.8(@playwright/test@1.58.2)(@types/react@19.2.14)(esbuild@0.27.4)(typescript@5.9.3)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) chrome-launcher@0.15.2: dependencies: - '@types/node': 25.6.0 + '@types/node': 25.5.0 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -22811,7 +22778,7 @@ snapshots: chromium-edge-launcher@0.2.0: dependencies: - '@types/node': 25.6.0 + '@types/node': 25.5.0 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -23097,13 +23064,13 @@ snapshots: - supports-color - ts-node - create-jest@29.7.0(@types/node@25.6.0): + create-jest@29.7.0(@types/node@25.5.0): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@25.6.0) + jest-config: 29.7.0(@types/node@25.5.0) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -23551,12 +23518,12 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.12)(pg@8.20.0): + drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.11)(pg@8.20.0): optionalDependencies: '@cloudflare/workers-types': 4.20260313.1 '@opentelemetry/api': 1.9.0 '@types/pg': 8.18.0 - bun-types: 1.3.12 + bun-types: 1.3.11 pg: 8.20.0 dset@3.1.4: {} @@ -25242,7 +25209,7 @@ snapshots: '@jest/expect': 30.3.0 '@jest/test-result': 30.3.0 '@jest/types': 30.3.0 - '@types/node': 24.12.0 + '@types/node': 25.5.0 chalk: 4.1.2 co: 4.6.0 dedent: 1.7.2 @@ -25281,16 +25248,16 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@25.6.0): + jest-cli@29.7.0(@types/node@25.5.0): dependencies: '@jest/core': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@25.6.0) + create-jest: 29.7.0(@types/node@25.5.0) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@25.6.0) + jest-config: 29.7.0(@types/node@25.5.0) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -25319,25 +25286,6 @@ snapshots: - supports-color - ts-node - jest-cli@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)): - dependencies: - '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) - '@jest/test-result': 30.3.0 - '@jest/types': 30.3.0 - chalk: 4.1.2 - exit-x: 0.2.2 - import-local: 3.2.0 - jest-config: 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) - jest-util: 30.3.0 - jest-validate: 30.3.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jest-config@29.7.0(@types/node@24.12.0): dependencies: '@babel/core': 7.29.0 @@ -25398,36 +25346,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@25.6.0): - dependencies: - '@babel/core': 7.29.0 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.29.0) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 13.0.6 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 25.6.0 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-config@30.3.0(@types/node@24.12.0)(esbuild-register@3.6.0(esbuild@0.27.4)): dependencies: '@babel/core': 7.29.0 @@ -25492,37 +25410,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)): - dependencies: - '@babel/core': 7.29.0 - '@jest/get-type': 30.1.0 - '@jest/pattern': 30.0.1 - '@jest/test-sequencer': 30.3.0 - '@jest/types': 30.3.0 - babel-jest: 30.3.0(@babel/core@7.29.0) - chalk: 4.1.2 - ci-info: 4.4.0 - deepmerge: 4.3.1 - glob: 13.0.6 - graceful-fs: 4.2.11 - jest-circus: 30.3.0 - jest-docblock: 30.2.0 - jest-environment-node: 30.3.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.3.0 - jest-runner: 30.3.0 - jest-util: 30.3.0 - jest-validate: 30.3.0 - parse-json: 5.2.0 - pretty-format: 30.3.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - esbuild-register: 3.6.0(esbuild@0.27.4) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -25575,7 +25462,7 @@ snapshots: '@jest/environment': 30.3.0 '@jest/fake-timers': 30.3.0 '@jest/types': 30.3.0 - '@types/node': 24.12.0 + '@types/node': 25.5.0 jest-mock: 30.3.0 jest-util: 30.3.0 jest-validate: 30.3.0 @@ -25601,7 +25488,7 @@ snapshots: jest-haste-map@30.3.0: dependencies: '@jest/types': 30.3.0 - '@types/node': 24.12.0 + '@types/node': 25.5.0 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -25680,10 +25567,10 @@ snapshots: '@types/node': 25.5.0 jest-util: 30.3.0 - jest-playwright-preset@4.0.0(jest-circus@29.7.0)(jest-environment-node@29.7.0)(jest-runner@29.7.0)(jest@29.7.0(@types/node@25.6.0)): + jest-playwright-preset@4.0.0(jest-circus@29.7.0)(jest-environment-node@29.7.0)(jest-runner@29.7.0)(jest@29.7.0(@types/node@25.5.0)): dependencies: expect-playwright: 0.8.0 - jest: 29.7.0(@types/node@25.6.0) + jest: 29.7.0(@types/node@25.5.0) jest-circus: 29.7.0 jest-environment-node: 29.7.0 jest-process-manager: 0.4.0 @@ -25794,7 +25681,7 @@ snapshots: '@jest/test-result': 30.3.0 '@jest/transform': 30.3.0 '@jest/types': 30.3.0 - '@types/node': 24.12.0 + '@types/node': 25.5.0 chalk: 4.1.2 emittery: 0.13.1 exit-x: 0.2.2 @@ -25850,7 +25737,7 @@ snapshots: '@jest/test-result': 30.3.0 '@jest/transform': 30.3.0 '@jest/types': 30.3.0 - '@types/node': 24.12.0 + '@types/node': 25.5.0 chalk: 4.1.2 cjs-module-lexer: 2.2.0 collect-v8-coverage: 1.0.3 @@ -25935,7 +25822,7 @@ snapshots: jest-util@30.3.0: dependencies: '@jest/types': 30.3.0 - '@types/node': 24.12.0 + '@types/node': 25.5.0 chalk: 4.1.2 ci-info: 4.4.0 graceful-fs: 4.2.11 @@ -25959,11 +25846,11 @@ snapshots: leven: 3.1.0 pretty-format: 30.3.0 - jest-watch-typeahead@2.2.2(jest@29.7.0(@types/node@25.6.0)): + jest-watch-typeahead@2.2.2(jest@29.7.0(@types/node@25.5.0)): dependencies: ansi-escapes: 6.2.1 chalk: 5.6.2 - jest: 29.7.0(@types/node@25.6.0) + jest: 29.7.0(@types/node@25.5.0) jest-regex-util: 29.6.3 jest-watcher: 29.7.0 slash: 5.1.0 @@ -25985,7 +25872,7 @@ snapshots: dependencies: '@jest/test-result': 30.3.0 '@jest/types': 30.3.0 - '@types/node': 24.12.0 + '@types/node': 25.5.0 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -25994,7 +25881,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 25.6.0 + '@types/node': 25.5.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -26007,7 +25894,7 @@ snapshots: jest-worker@30.3.0: dependencies: - '@types/node': 24.12.0 + '@types/node': 25.5.0 '@ungap/structured-clone': 1.3.0 jest-util: 30.3.0 merge-stream: 2.0.0 @@ -26025,12 +25912,12 @@ snapshots: - supports-color - ts-node - jest@29.7.0(@types/node@25.6.0): + jest@29.7.0(@types/node@25.5.0): dependencies: '@jest/core': 29.7.0 '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@25.6.0) + jest-cli: 29.7.0(@types/node@25.5.0) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -26050,19 +25937,6 @@ snapshots: - supports-color - ts-node - jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)): - dependencies: - '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) - '@jest/types': 30.3.0 - import-local: 3.2.0 - jest-cli: 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jimp-compact@0.16.1: {} jiti@2.6.1: {} @@ -27302,33 +27176,6 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.5))(react@19.2.5): - dependencies: - '@next/env': 16.1.6 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.8 - caniuse-lite: 1.0.30001779 - postcss: 8.4.31 - react: 19.2.5 - react-dom: 19.2.4(react@19.2.5) - styled-jsx: 5.1.6(react@19.2.5) - optionalDependencies: - '@next/swc-darwin-arm64': 16.1.6 - '@next/swc-darwin-x64': 16.1.6 - '@next/swc-linux-arm64-gnu': 16.1.6 - '@next/swc-linux-arm64-musl': 16.1.6 - '@next/swc-linux-x64-gnu': 16.1.6 - '@next/swc-linux-x64-musl': 16.1.6 - '@next/swc-win32-arm64-msvc': 16.1.6 - '@next/swc-win32-x64-msvc': 16.1.6 - '@opentelemetry/api': 1.9.0 - '@playwright/test': 1.58.2 - babel-plugin-react-compiler: 1.0.0 - sharp: 0.34.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - no-case@3.0.4: dependencies: lower-case: 2.0.2 @@ -28099,7 +27946,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 24.12.0 + '@types/node': 25.5.0 long: 5.3.2 proxy-addr@2.0.7: @@ -28223,11 +28070,6 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 - react-dom@19.2.4(react@19.2.5): - dependencies: - react: 19.2.5 - scheduler: 0.27.0 - react-dropzone@14.4.1(react@19.2.4): dependencies: attr-accept: 2.2.5 @@ -28553,8 +28395,6 @@ snapshots: react@19.2.4: {} - react@19.2.5: {} - readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -29325,7 +29165,7 @@ snapshots: '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4 + '@vitest/mocker': 3.2.4(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/spy': 3.2.4 better-opn: 3.0.2 esbuild: 0.27.4 @@ -29341,13 +29181,13 @@ snapshots: - utf-8-validate - vite - storybook@9.1.20(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + storybook@9.1.20(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@storybook/global': 5.0.0 '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/spy': 3.2.4 better-opn: 3.0.2 esbuild: 0.27.4 @@ -29598,11 +29438,11 @@ snapshots: optionalDependencies: '@types/node': 24.12.0 - stripe@19.3.0(@types/node@25.6.0): + stripe@19.3.0(@types/node@25.5.0): dependencies: qs: 6.15.0 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.5.0 strnum@2.2.0: {} @@ -29631,11 +29471,6 @@ snapshots: optionalDependencies: '@babel/core': 7.29.0 - styled-jsx@5.1.6(react@19.2.5): - dependencies: - client-only: 0.0.1 - react: 19.2.5 - styled-jsx@5.1.7(@babel/core@7.29.0)(react@19.2.4): dependencies: client-only: 0.0.1 @@ -29970,8 +29805,6 @@ snapshots: undici-types@7.18.2: {} - undici-types@7.19.2: {} - undici@6.24.1: {} undici@7.18.2: {} @@ -30284,28 +30117,6 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.7.0 - pathe: 2.0.3 - vite: 8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - transitivePeerDependencies: - - '@types/node' - - '@vitejs/devtools' - - esbuild - - jiti - - less - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@oxc-project/runtime': 0.115.0 @@ -30340,23 +30151,6 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): - dependencies: - '@oxc-project/runtime': 0.115.0 - lightningcss: 1.30.1 - picomatch: 4.0.3 - postcss: 8.5.8 - rolldown: 1.0.0-rc.9 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 25.6.0 - esbuild: 0.27.4 - fsevents: 2.3.3 - jiti: 2.6.1 - terser: 5.46.0 - tsx: 4.21.0 - yaml: 2.8.2 - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 @@ -30445,50 +30239,6 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.6.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): - dependencies: - '@types/chai': 5.2.3 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - debug: 4.4.3 - expect-type: 1.3.0 - magic-string: 0.30.21 - pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinyglobby: 0.2.15 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 - vite: 8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/debug': 4.1.12 - '@types/node': 25.6.0 - '@vitest/ui': 3.2.4(vitest@3.2.4) - transitivePeerDependencies: - - '@vitejs/devtools' - - esbuild - - jiti - - less - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.1.0 @@ -30528,10 +30278,10 @@ snapshots: - tsx - yaml - vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.1.0 - '@vitest/mocker': 4.1.0(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.1.0(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.1.0 '@vitest/runner': 4.1.0 '@vitest/snapshot': 4.1.0 @@ -30548,11 +30298,11 @@ snapshots: tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 - '@types/node': 25.6.0 + '@types/node': 25.5.0 transitivePeerDependencies: - '@vitejs/devtools' - esbuild diff --git a/services/wasteland/dist-types/db/tables/wasteland-config.table.d.ts b/services/wasteland/dist-types/db/tables/wasteland-config.table.d.ts new file mode 100644 index 0000000000..734135b07a --- /dev/null +++ b/services/wasteland/dist-types/db/tables/wasteland-config.table.d.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; +export declare const WastelandConfigRecord: z.ZodObject<{ + wasteland_id: z.ZodString; + name: z.ZodString; + owner_type: z.ZodEnum<{ + org: "org"; + user: "user"; + }>; + owner_user_id: z.ZodNullable; + organization_id: z.ZodNullable; + dolthub_upstream: z.ZodNullable; + visibility: z.ZodEnum<{ + private: "private"; + public: "public"; + }>; + status: z.ZodEnum<{ + active: "active"; + deleted: "deleted"; + }>; + created_at: z.ZodString; + updated_at: z.ZodString; +}, z.core.$strip>; +export type WastelandConfigRecord = z.output; +export declare const wasteland_config: import("../../util/table").TableQueryInterpolator<{ + name: "wasteland_config"; + columns: ("created_at" | "dolthub_upstream" | "name" | "organization_id" | "owner_type" | "owner_user_id" | "status" | "updated_at" | "visibility" | "wasteland_id")[]; +}>; +export declare function createTableWastelandConfig(): string; diff --git a/services/wasteland/dist-types/db/tables/wasteland-connected-towns.table.d.ts b/services/wasteland/dist-types/db/tables/wasteland-connected-towns.table.d.ts new file mode 100644 index 0000000000..86128e75d6 --- /dev/null +++ b/services/wasteland/dist-types/db/tables/wasteland-connected-towns.table.d.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; +export declare const WastelandConnectedTownRecord: z.ZodObject<{ + town_id: z.ZodString; + wasteland_id: z.ZodString; + connected_by: z.ZodString; + connected_at: z.ZodString; +}, z.core.$strip>; +export type WastelandConnectedTownRecord = z.output; +export declare const wasteland_connected_towns: import("../../util/table").TableQueryInterpolator<{ + name: "wasteland_connected_towns"; + columns: ("connected_at" | "connected_by" | "town_id" | "wasteland_id")[]; +}>; +export declare function createTableWastelandConnectedTowns(): string; diff --git a/services/wasteland/dist-types/db/tables/wasteland-credentials.table.d.ts b/services/wasteland/dist-types/db/tables/wasteland-credentials.table.d.ts new file mode 100644 index 0000000000..c2197302f1 --- /dev/null +++ b/services/wasteland/dist-types/db/tables/wasteland-credentials.table.d.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; +export declare const WastelandCredentialRecord: z.ZodObject<{ + user_id: z.ZodString; + wasteland_id: z.ZodString; + encrypted_token: z.ZodString; + dolthub_org: z.ZodString; + rig_handle: z.ZodNullable; + is_upstream_admin: z.ZodPipe, z.ZodTransform>; + connected_at: z.ZodString; +}, z.core.$strip>; +export type WastelandCredentialRecord = z.output; +export declare const wasteland_credentials: import("../../util/table").TableQueryInterpolator<{ + name: "wasteland_credentials"; + columns: ("connected_at" | "dolthub_org" | "encrypted_token" | "is_upstream_admin" | "rig_handle" | "user_id" | "wasteland_id")[]; +}>; +export declare function createTableWastelandCredentials(): string; +/** + * Idempotent migration that adds `is_upstream_admin` to existing rows. + * Safe to call on every DO init — SQLite's ALTER TABLE IF NOT EXISTS + * isn't available, so we catch the "duplicate column" error via a + * presence check. + */ +export declare function migrateAddIsUpstreamAdmin(sql: SqlStorage): void; diff --git a/services/wasteland/dist-types/db/tables/wasteland-members.table.d.ts b/services/wasteland/dist-types/db/tables/wasteland-members.table.d.ts new file mode 100644 index 0000000000..ed1b7ce87e --- /dev/null +++ b/services/wasteland/dist-types/db/tables/wasteland-members.table.d.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; +export declare const WastelandMemberRecord: z.ZodObject<{ + member_id: z.ZodString; + wasteland_id: z.ZodString; + user_id: z.ZodString; + role: z.ZodEnum<{ + contributor: "contributor"; + maintainer: "maintainer"; + owner: "owner"; + }>; + trust_level: z.ZodNumber; + joined_at: z.ZodString; +}, z.core.$strip>; +export type WastelandMemberRecord = z.output; +export declare const wasteland_members: import("../../util/table").TableQueryInterpolator<{ + name: "wasteland_members"; + columns: ("joined_at" | "member_id" | "role" | "trust_level" | "user_id" | "wasteland_id")[]; +}>; +export declare function createTableWastelandMembers(): string; diff --git a/services/wasteland/dist-types/db/tables/wasteland-registry.table.d.ts b/services/wasteland/dist-types/db/tables/wasteland-registry.table.d.ts new file mode 100644 index 0000000000..990923ad18 --- /dev/null +++ b/services/wasteland/dist-types/db/tables/wasteland-registry.table.d.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +export declare const WastelandRegistryRecord: z.ZodObject<{ + wasteland_id: z.ZodString; + owner_type: z.ZodEnum<{ + org: "org"; + user: "user"; + }>; + owner_user_id: z.ZodNullable; + organization_id: z.ZodNullable; + name: z.ZodString; + created_at: z.ZodString; +}, z.core.$strip>; +export type WastelandRegistryRecord = z.output; +export declare const wasteland_registry: import("../../util/table").TableQueryInterpolator<{ + name: "wasteland_registry"; + columns: ("created_at" | "name" | "organization_id" | "owner_type" | "owner_user_id" | "wasteland_id")[]; +}>; +export declare function createTableWastelandRegistry(): string; diff --git a/services/wasteland/dist-types/db/tables/wasteland-wanted-board.table.d.ts b/services/wasteland/dist-types/db/tables/wasteland-wanted-board.table.d.ts new file mode 100644 index 0000000000..d700612cac --- /dev/null +++ b/services/wasteland/dist-types/db/tables/wasteland-wanted-board.table.d.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +export declare const WastelandWantedBoardRecord: z.ZodObject<{ + item_id: z.ZodString; + wasteland_id: z.ZodString; + title: z.ZodString; + description: z.ZodString; + status: z.ZodEnum<{ + claimed: "claimed"; + done: "done"; + open: "open"; + }>; + priority: z.ZodEnum<{ + critical: "critical"; + high: "high"; + low: "low"; + medium: "medium"; + }>; + type: z.ZodEnum<{ + bug: "bug"; + docs: "docs"; + feature: "feature"; + other: "other"; + }>; + claimed_by: z.ZodNullable; + evidence: z.ZodNullable; + created_at: z.ZodString; + updated_at: z.ZodString; +}, z.core.$strip>; +export type WastelandWantedBoardRecord = z.output; +export declare const wasteland_wanted_board: import("../../util/table").TableQueryInterpolator<{ + name: "wasteland_wanted_board"; + columns: ("claimed_by" | "created_at" | "description" | "evidence" | "item_id" | "priority" | "status" | "title" | "type" | "updated_at" | "wasteland_id")[]; +}>; +export declare function createTableWastelandWantedBoard(): string; diff --git a/services/wasteland/dist-types/dos/Wasteland.do.d.ts b/services/wasteland/dist-types/dos/Wasteland.do.d.ts new file mode 100644 index 0000000000..f7f79da3e8 --- /dev/null +++ b/services/wasteland/dist-types/dos/Wasteland.do.d.ts @@ -0,0 +1,95 @@ +import { DurableObject } from 'cloudflare:workers'; +export type WastelandConfigResult = { + wasteland_id: string; + name: string; + owner_type: 'user' | 'org'; + owner_user_id: string | null; + organization_id: string | null; + dolthub_upstream: string | null; + visibility: 'public' | 'private'; + status: 'active' | 'deleted'; + created_at: string; + updated_at: string; +}; +export type WastelandMemberResult = { + member_id: string; + user_id: string; + trust_level: number; + role: 'contributor' | 'maintainer' | 'owner'; + joined_at: string; +}; +export type InitializeWastelandInput = { + wasteland_id: string; + name: string; + owner_type: 'user' | 'org'; + owner_user_id: string | null; + organization_id: string | null; + dolthub_upstream: string | null; + visibility: 'public' | 'private'; +}; +export type UpdateWastelandConfigInput = { + name?: string; + visibility?: 'public' | 'private'; + dolthub_upstream?: string | null; + status?: 'active' | 'deleted'; +}; +export type WastelandCredentialResult = { + user_id: string; + encrypted_token: string; + dolthub_org: string; + rig_handle: string | null; + is_upstream_admin: boolean; + connected_at: string; +}; +export type ConnectedTownResult = { + town_id: string; + wasteland_id: string; + connected_by: string; + connected_at: string; +}; +export type WantedItemResult = { + item_id: string; + title: string; + description: string; + status: 'open' | 'claimed' | 'done'; + priority: 'low' | 'medium' | 'high' | 'critical'; + type: 'feature' | 'bug' | 'docs' | 'other'; + claimed_by: string | null; + evidence: string | null; + created_at: string; + updated_at: string; +}; +export declare class WastelandDO extends DurableObject { + private state; + private sql; + private wastelandId; + constructor(state: DurableObjectState, env: Env); + private initializeDatabase; + initializeWasteland(input: InitializeWastelandInput): Promise; + getConfig(): Promise; + updateConfig(input: UpdateWastelandConfigInput): Promise; + listMembers(): Promise; + addMember(userId: string, role: string, trustLevel: number): Promise; + removeMember(memberId: string): Promise; + getMember(userId: string): Promise; + updateMember(memberId: string, update: { + role?: string; + trust_level?: number; + }): Promise; + storeCredential(input: { + userId: string; + encryptedToken: string; + dolthubOrg: string; + rigHandle?: string; + isUpstreamAdmin?: boolean; + }): Promise; + getCredential(userId: string): Promise; + setIsUpstreamAdmin(userId: string, isUpstreamAdmin: boolean): Promise; + deleteCredential(userId: string): Promise; + connectTown(townId: string, userId: string): Promise; + disconnectTown(townId: string): Promise; + listConnectedTowns(): Promise; + getWantedBoard(): Promise; + refreshWantedBoard(): Promise; +} +export declare function getWastelandDOStub(env: Env, wastelandId: string): DurableObjectStub; diff --git a/services/wasteland/dist-types/dos/WastelandContainer.do.d.ts b/services/wasteland/dist-types/dos/WastelandContainer.do.d.ts new file mode 100644 index 0000000000..772fbcb0df --- /dev/null +++ b/services/wasteland/dist-types/dos/WastelandContainer.do.d.ts @@ -0,0 +1,34 @@ +import { Container } from '@cloudflare/containers'; +/** + * WastelandContainerDO — a Cloudflare Container per wasteland. + * + * Runs the `wl` CLI and a lightweight control server for protocol + * operations (browse, claim, post, sync) against DoltHub. + * + * This DO is intentionally thin. It manages container lifecycle and proxies + * ALL requests directly to the container via the base Container class's fetch(). + * + * On boot, the control server reads WL_UPSTREAM, DOLTHUB_TOKEN, and + * DOLTHUB_ORG from its environment (injected via envVars) and runs + * `wl join` automatically — no callback to the worker required. + */ +export declare class WastelandContainerDO extends Container { + defaultPort: number; + sleepAfter: string; + envVars: Record; + constructor(ctx: DurableObjectState, env: Env); + /** + * Store an env var that will be injected into the container OS environment. + * Takes effect on the next container boot (or immediately if the container + * hasn't started yet). Call this from the WastelandDO when storing credentials. + */ + setEnvVar(key: string, value: string): Promise; + deleteEnvVar(key: string): Promise; + onStart(): void; + onStop({ exitCode, reason }: { + exitCode: number; + reason: string; + }): void; + onError(error: unknown): void; +} +export declare function getWastelandContainerStub(env: Env, wastelandId: string): DurableObjectStub; diff --git a/services/wasteland/dist-types/dos/WastelandRegistry.do.d.ts b/services/wasteland/dist-types/dos/WastelandRegistry.do.d.ts new file mode 100644 index 0000000000..802392e841 --- /dev/null +++ b/services/wasteland/dist-types/dos/WastelandRegistry.do.d.ts @@ -0,0 +1,34 @@ +import { DurableObject } from 'cloudflare:workers'; +import { WastelandRegistryRecord } from '../db/tables/wasteland-registry.table'; +/** + * WastelandRegistryDO — singleton registry that indexes wasteland ownership. + * + * Because each WastelandDO is per-wasteland, we need a central index to + * answer "which wastelands does user X own?" or "which wastelands belong + * to org Y?". This singleton (keyed by fixed name 'registry') maintains + * that mapping. + * + * All creates/deletes in the tRPC router update this registry so + * listWastelands can resolve ownership without scanning every WastelandDO. + */ +export declare class WastelandRegistryDO extends DurableObject { + private sql; + private initPromise; + constructor(ctx: DurableObjectState, env: Env); + private ensureInitialized; + private initializeDatabase; + register(input: { + wasteland_id: string; + owner_type: 'user' | 'org'; + owner_user_id: string | null; + organization_id: string | null; + name: string; + }): Promise; + unregister(wastelandId: string): Promise; + listByUser(userId: string): Promise; + listByOrg(orgId: string): Promise; + listAll(): Promise; + /** Return the total number of registered (active) wastelands. */ + countAll(): Promise; +} +export declare function getWastelandRegistryStub(env: Env): DurableObjectStub; diff --git a/services/wasteland/dist-types/dos/wasteland/config.d.ts b/services/wasteland/dist-types/dos/wasteland/config.d.ts new file mode 100644 index 0000000000..fd960cea00 --- /dev/null +++ b/services/wasteland/dist-types/dos/wasteland/config.d.ts @@ -0,0 +1,31 @@ +export type InitializeWastelandInput = { + wasteland_id: string; + name: string; + owner_type: 'user' | 'org'; + owner_user_id: string | null; + organization_id: string | null; + dolthub_upstream: string | null; + visibility: 'public' | 'private'; +}; +export type UpdateWastelandConfigInput = { + name?: string; + visibility?: 'public' | 'private'; + dolthub_upstream?: string | null; + status?: 'active' | 'deleted'; +}; +export type WastelandConfigResult = { + wasteland_id: string; + name: string; + owner_type: 'user' | 'org'; + owner_user_id: string | null; + organization_id: string | null; + dolthub_upstream: string | null; + visibility: 'public' | 'private'; + status: 'active' | 'deleted'; + created_at: string; + updated_at: string; +}; +export declare function initializeDatabase(sql: SqlStorage): void; +export declare function initializeWasteland(sql: SqlStorage, input: InitializeWastelandInput): WastelandConfigResult; +export declare function getConfig(sql: SqlStorage, wastelandId: string): WastelandConfigResult | null; +export declare function updateConfig(sql: SqlStorage, wastelandId: string, update: UpdateWastelandConfigInput): WastelandConfigResult; diff --git a/services/wasteland/dist-types/dos/wasteland/credentials.d.ts b/services/wasteland/dist-types/dos/wasteland/credentials.d.ts new file mode 100644 index 0000000000..90ca3b453e --- /dev/null +++ b/services/wasteland/dist-types/dos/wasteland/credentials.d.ts @@ -0,0 +1,22 @@ +export type WastelandCredentialResult = { + user_id: string; + encrypted_token: string; + dolthub_org: string; + rig_handle: string | null; + is_upstream_admin: boolean; + connected_at: string; +}; +export declare function initializeDatabase(sql: SqlStorage): void; +export declare function storeCredential(sql: SqlStorage, wastelandId: string, userId: string, input: { + encryptedToken: string; + dolthubOrg: string; + rigHandle?: string; + isUpstreamAdmin?: boolean; +}): WastelandCredentialResult; +export declare function getCredential(sql: SqlStorage, wastelandId: string, userId: string): WastelandCredentialResult | null; +/** + * Update the `is_upstream_admin` flag for an existing credential. + * Returns the updated row, or null if no credential exists. + */ +export declare function setIsUpstreamAdmin(sql: SqlStorage, wastelandId: string, userId: string, isUpstreamAdmin: boolean): WastelandCredentialResult | null; +export declare function deleteCredential(sql: SqlStorage, wastelandId: string, userId: string): void; diff --git a/services/wasteland/dist-types/dos/wasteland/members.d.ts b/services/wasteland/dist-types/dos/wasteland/members.d.ts new file mode 100644 index 0000000000..869c8da60c --- /dev/null +++ b/services/wasteland/dist-types/dos/wasteland/members.d.ts @@ -0,0 +1,16 @@ +export type WastelandMemberResult = { + member_id: string; + user_id: string; + trust_level: number; + role: 'contributor' | 'maintainer' | 'owner'; + joined_at: string; +}; +export declare function initializeDatabase(sql: SqlStorage): void; +export declare function listMembers(sql: SqlStorage, wastelandId: string): WastelandMemberResult[]; +export declare function addMember(sql: SqlStorage, wastelandId: string, userId: string, role: string, trustLevel: number): string; +export declare function removeMember(sql: SqlStorage, memberId: string): void; +export declare function getMember(sql: SqlStorage, wastelandId: string, userId: string): WastelandMemberResult | null; +export declare function updateMember(sql: SqlStorage, wastelandId: string, memberId: string, update: { + role?: string; + trust_level?: number; +}): WastelandMemberResult | null; diff --git a/services/wasteland/dist-types/dos/wasteland/towns.d.ts b/services/wasteland/dist-types/dos/wasteland/towns.d.ts new file mode 100644 index 0000000000..83cd4492d3 --- /dev/null +++ b/services/wasteland/dist-types/dos/wasteland/towns.d.ts @@ -0,0 +1,10 @@ +export type ConnectedTownResult = { + town_id: string; + wasteland_id: string; + connected_by: string; + connected_at: string; +}; +export declare function initializeDatabase(sql: SqlStorage): void; +export declare function connectTown(sql: SqlStorage, wastelandId: string, townId: string, userId: string): ConnectedTownResult; +export declare function disconnectTown(sql: SqlStorage, wastelandId: string, townId: string): void; +export declare function listConnectedTowns(sql: SqlStorage, wastelandId: string): ConnectedTownResult[]; diff --git a/services/wasteland/dist-types/dos/wasteland/wanted-board.d.ts b/services/wasteland/dist-types/dos/wasteland/wanted-board.d.ts new file mode 100644 index 0000000000..2a1bf4f346 --- /dev/null +++ b/services/wasteland/dist-types/dos/wasteland/wanted-board.d.ts @@ -0,0 +1,15 @@ +export type WantedItemResult = { + item_id: string; + title: string; + description: string; + status: 'open' | 'claimed' | 'done'; + priority: 'low' | 'medium' | 'high' | 'critical'; + type: 'feature' | 'bug' | 'docs' | 'other'; + claimed_by: string | null; + evidence: string | null; + created_at: string; + updated_at: string; +}; +export declare function initializeDatabase(sql: SqlStorage): void; +export declare function getWantedBoard(sql: SqlStorage, wastelandId: string): WantedItemResult[]; +export declare function refreshWantedBoard(sql: SqlStorage, wastelandId: string): WantedItemResult[]; diff --git a/services/wasteland/dist-types/inbox/inbox-classifier.d.ts b/services/wasteland/dist-types/inbox/inbox-classifier.d.ts new file mode 100644 index 0000000000..b8c35fd331 --- /dev/null +++ b/services/wasteland/dist-types/inbox/inbox-classifier.d.ts @@ -0,0 +1,85 @@ +declare const WL_VERBS: readonly ["post", "claim", "unclaim", "done", "update", "delete", "accept", "accept-upstream", "reject", "close", "close-upstream"]; +type WlVerb = (typeof WL_VERBS)[number]; +type ParsedCommit = { + kind: 'wl'; + verb: WlVerb; + itemId: string; + reason?: string; +} | { + kind: 'register'; + handle: string; +} | { + kind: 'unknown'; + subject: string; +}; +/** + * Parse a commit subject against the closed grammar produced by the `wl` CLI: + * - `wl {verb}: {wanted-id}[ — {reason}]` (reason only on `reject`) + * - `Register rig: {handle}` (no leading `wl`, capital R) + * Anything else returns `{ kind: 'unknown' }` so the card renders as foreign. + */ +export declare function parseCommitSubject(subject: string): ParsedCommit; +type InboxCardBase = { + pull_id: string; + title: string; + state: string; + from_branch: string | null; + submitter: string | null; + creator_name: string | null; + created_at: string | null; + updated_at: string | null; +}; +export type InboxItem = InboxCardBase & ({ + kind: 'rig-registration'; + handle: string; + display_name: string | null; + dolthub_org: string | null; + owner_email: string | null; + hop_uri: string | null; + gt_version: string | null; +} | { + kind: 'wanted-post'; + item_id: string; + item_title: string; + description: string | null; + type: string | null; + priority: string | null; + effort_level: string | null; + tags: string | null; + posted_by: string | null; +} | { + kind: 'wanted-edit'; + subkind: 'update' | 'delete' | 'unclaim'; + item_id: string; + item_title: string; + submitter_is_poster: boolean | null; + posted_by: string | null; + status_transition: string | null; +} | { + kind: 'work-submission'; + item_id: string; + item_title: string; + claimer: string; + has_done: boolean; + evidence_url: string | null; + completion_id: string | null; +} | { + kind: 'admin-action'; + subkind: 'accept' | 'accept-upstream' | 'reject' | 'close' | 'close-upstream'; + item_id: string; + item_title: string; + worker: string | null; + acceptor: string | null; + reject_reason: string | null; + stamp: { + quality: string | null; + severity: string | null; + skill_tags: string | null; + message: string | null; + } | null; +} | { + kind: 'unknown'; + commit_subjects: string[]; +}); +export declare function listInboxItems(upstream: string, token: string): Promise; +export {}; diff --git a/services/wasteland/dist-types/middleware/analytics.middleware.d.ts b/services/wasteland/dist-types/middleware/analytics.middleware.d.ts new file mode 100644 index 0000000000..1dd2c1f002 --- /dev/null +++ b/services/wasteland/dist-types/middleware/analytics.middleware.d.ts @@ -0,0 +1,18 @@ +import type { Context, Next } from 'hono'; +import type { WastelandEnv } from '../wasteland.worker'; +/** + * Captures a high-resolution start timestamp very early in the request + * lifecycle. Must be the first middleware registered. + */ +export declare function timingMiddleware(c: Context, next: Next): Promise; +/** + * Wraps an individual HTTP route handler to emit an analytics event. + * Applied per-route, not as global middleware, + * so it has access to the matched route pattern. + * + * Usage: + * app.post('/api/wastelands', + * c => instrumented(c, 'POST /api/wastelands', + * () => handleCreateWasteland(c, c.req.param()))); + */ +export declare function instrumented(c: Context, route: string, handler: () => Promise): Promise; diff --git a/services/wasteland/dist-types/middleware/auth.middleware.d.ts b/services/wasteland/dist-types/middleware/auth.middleware.d.ts new file mode 100644 index 0000000000..bf5515d1c0 --- /dev/null +++ b/services/wasteland/dist-types/middleware/auth.middleware.d.ts @@ -0,0 +1,11 @@ +export type JwtOrgMembership = { + orgId: string; + role: 'owner' | 'member' | 'billing_manager'; +}; +export type AuthVariables = { + kiloUserId: string; + kiloIsAdmin: boolean; + kiloApiTokenPepper: string | null; + kiloOrgMemberships: JwtOrgMembership[]; + requestStartTime: number; +}; diff --git a/services/wasteland/dist-types/middleware/kilo-auth.middleware.d.ts b/services/wasteland/dist-types/middleware/kilo-auth.middleware.d.ts new file mode 100644 index 0000000000..6e07aee617 --- /dev/null +++ b/services/wasteland/dist-types/middleware/kilo-auth.middleware.d.ts @@ -0,0 +1,9 @@ +import type { WastelandEnv } from '../wasteland.worker'; +/** + * Auth middleware that validates Kilo user JWTs (signed with NEXTAUTH_SECRET). + * Used for dashboard/user-facing routes where the Next.js app sends a + * Bearer token on behalf of the logged-in user. + * + * Sets `kiloUserId` on the Hono context. + */ +export declare const kiloAuthMiddleware: any; diff --git a/services/wasteland/dist-types/trpc/init.d.ts b/services/wasteland/dist-types/trpc/init.d.ts new file mode 100644 index 0000000000..5a7d9cd3e9 --- /dev/null +++ b/services/wasteland/dist-types/trpc/init.d.ts @@ -0,0 +1,40 @@ +import type { JwtOrgMembership } from '../middleware/auth.middleware'; +export type TRPCContext = { + env: Env; + userId: string; + isAdmin: boolean; + apiTokenPepper: string | null; + orgMemberships: JwtOrgMembership[]; +}; +export declare const router: import("@trpc/server").TRPCRouterBuilder<{ + ctx: TRPCContext; + meta: object; + errorShape: import("@trpc/server").TRPCDefaultErrorShape; + transformer: false; +}>; +/** + * Base procedure — requires a valid Kilo JWT (enforced by kiloAuthMiddleware + * running before tRPC). The userId is extracted from the JWT and set on the + * Hono context by kiloAuthMiddleware, then forwarded into the tRPC context + * by the createContext callback in wasteland.worker.ts. + * + * Also enforces per-user rate limits for operations that have them configured. + */ +export declare const procedure: import("@trpc/server").TRPCProcedureBuilder; +/** + * Admin-only procedure — requires `isAdmin` on the JWT. Used for admin + * endpoints that bypass per-user ownership checks. + */ +export declare const adminProcedure: import("@trpc/server").TRPCProcedureBuilder; diff --git a/services/wasteland/dist-types/trpc/ownership.d.ts b/services/wasteland/dist-types/trpc/ownership.d.ts new file mode 100644 index 0000000000..56c1535fe9 --- /dev/null +++ b/services/wasteland/dist-types/trpc/ownership.d.ts @@ -0,0 +1,12 @@ +import type { TRPCContext } from './init'; +type WastelandOwnershipResult = { + type: 'user'; + userId: string; +} | { + type: 'org'; + orgId: string; +} | { + type: 'admin'; +}; +export declare function resolveWastelandOwnership(env: Env, ctx: TRPCContext, wastelandId: string): Promise; +export {}; diff --git a/services/wasteland/dist-types/trpc/router.d.ts b/services/wasteland/dist-types/trpc/router.d.ts new file mode 100644 index 0000000000..a8059f0c51 --- /dev/null +++ b/services/wasteland/dist-types/trpc/router.d.ts @@ -0,0 +1,1179 @@ +import type { TRPCContext } from './init'; +export declare const wastelandRouter: import("@trpc/server").TRPCBuiltRouter<{ + ctx: TRPCContext; + meta: object; + errorShape: import("@trpc/server").TRPCDefaultErrorShape; + transformer: false; +}, import("@trpc/server").TRPCDecorateCreateRouterOptions<{ + createWasteland: import("@trpc/server").TRPCMutationProcedure<{ + input: { + name: string; + ownerType: "org" | "user"; + organizationId?: string | undefined; + dolthubUpstream?: string | undefined; + visibility?: "private" | "public" | undefined; + }; + output: { + wasteland_id: string; + name: string; + owner_type: "org" | "user"; + owner_user_id: string | null; + organization_id: string | null; + dolthub_upstream: string | null; + visibility: "private" | "public"; + status: "active" | "deleted"; + created_at: string; + updated_at: string; + }; + meta: object; + }>; + createUpstream: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + upstream: string; + rigHandle?: string | undefined; + rigDisplayName?: string | undefined; + rigEmail?: string | undefined; + }; + output: { + success: boolean; + }; + meta: object; + }>; + listWastelands: import("@trpc/server").TRPCQueryProcedure<{ + input: { + organizationId?: string | undefined; + }; + output: { + wasteland_id: string; + name: string; + owner_type: "org" | "user"; + owner_user_id: string | null; + organization_id: string | null; + dolthub_upstream: string | null; + visibility: "private" | "public"; + status: "active" | "deleted"; + created_at: string; + updated_at: string; + }[]; + meta: object; + }>; + getWasteland: import("@trpc/server").TRPCQueryProcedure<{ + input: { + wastelandId: string; + }; + output: { + wasteland_id: string; + name: string; + owner_type: "org" | "user"; + owner_user_id: string | null; + organization_id: string | null; + dolthub_upstream: string | null; + visibility: "private" | "public"; + status: "active" | "deleted"; + created_at: string; + updated_at: string; + }; + meta: object; + }>; + deleteWasteland: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + }; + output: { + success: boolean; + }; + meta: object; + }>; + adminListWastelands: import("@trpc/server").TRPCQueryProcedure<{ + input: void; + output: { + wasteland_id: string; + name: string; + owner_type: "org" | "user"; + owner_user_id: string | null; + organization_id: string | null; + dolthub_upstream: string | null; + visibility: "private" | "public"; + status: "active" | "deleted"; + created_at: string; + updated_at: string; + }[]; + meta: object; + }>; + listMembers: import("@trpc/server").TRPCQueryProcedure<{ + input: { + wastelandId: string; + }; + output: { + member_id: string; + user_id: string; + trust_level: number; + role: "contributor" | "maintainer" | "owner"; + joined_at: string; + }[]; + meta: object; + }>; + addMember: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + userId: string; + role?: "contributor" | "maintainer" | "owner" | undefined; + trustLevel?: number | undefined; + }; + output: { + member_id: string; + user_id: string; + trust_level: number; + role: "contributor" | "maintainer" | "owner"; + joined_at: string; + }; + meta: object; + }>; + removeMember: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + memberId: string; + }; + output: { + success: boolean; + }; + meta: object; + }>; + updateMember: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + memberId: string; + role?: "contributor" | "maintainer" | "owner" | undefined; + trustLevel?: number | undefined; + }; + output: { + member_id: string; + user_id: string; + trust_level: number; + role: "contributor" | "maintainer" | "owner"; + joined_at: string; + }; + meta: object; + }>; + updateWastelandConfig: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + name?: string | undefined; + visibility?: "private" | "public" | undefined; + dolthubUpstream?: string | undefined; + }; + output: { + wasteland_id: string; + name: string; + owner_type: "org" | "user"; + owner_user_id: string | null; + organization_id: string | null; + dolthub_upstream: string | null; + visibility: "private" | "public"; + status: "active" | "deleted"; + created_at: string; + updated_at: string; + }; + meta: object; + }>; + storeCredential: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + dolthubToken: string; + dolthubOrg: string; + rigHandle?: string | undefined; + doltCredsJwk?: string | undefined; + doltUserName?: string | undefined; + doltUserEmail?: string | undefined; + isUpstreamAdmin?: boolean | undefined; + }; + output: { + user_id: string; + dolthub_org: string; + rig_handle: string | null; + is_upstream_admin: boolean; + connected_at: string; + }; + meta: object; + }>; + getCredentialStatus: import("@trpc/server").TRPCQueryProcedure<{ + input: { + wastelandId: string; + }; + output: { + user_id: string; + dolthub_org: string; + rig_handle: string | null; + is_upstream_admin: boolean; + connected_at: string; + } | null; + meta: object; + }>; + setUpstreamAdmin: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + isUpstreamAdmin: boolean; + }; + output: { + user_id: string; + dolthub_org: string; + rig_handle: string | null; + is_upstream_admin: boolean; + connected_at: string; + } | null; + meta: object; + }>; + deleteCredential: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + }; + output: { + success: boolean; + }; + meta: object; + }>; + containerStatus: import("@trpc/server").TRPCQueryProcedure<{ + input: { + wastelandId: string; + }; + output: { + joined: boolean; + upstream: string | null; + dolthubOrg: string | null; + hasToken: boolean; + hasJwk: boolean; + doltCredPubKey: string | null; + wlVersion: string; + uptime: number; + lastOperation: string | null; + }; + meta: object; + }>; + containerJoin: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + }; + output: { + success: boolean; + }; + meta: object; + }>; + connectKiloTown: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + townId: string; + }; + output: { + town_id: string; + wasteland_id: string; + connected_by: string; + connected_at: string; + }; + meta: object; + }>; + disconnectKiloTown: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + townId: string; + }; + output: { + success: boolean; + }; + meta: object; + }>; + listConnectedTowns: import("@trpc/server").TRPCQueryProcedure<{ + input: { + wastelandId: string; + }; + output: { + town_id: string; + wasteland_id: string; + connected_by: string; + connected_at: string; + }[]; + meta: object; + }>; + browseWantedBoard: import("@trpc/server").TRPCQueryProcedure<{ + input: { + wastelandId: string; + }; + output: { + id: string; + title: string; + description: string | null; + project: string | null; + type: string | null; + priority: string | number | null; + tags: string | null; + posted_by: string | null; + claimed_by: string | null; + status: string; + effort_level: string | null; + evidence_url: string | null; + sandbox_required: string | number | null; + sandbox_scope: string | null; + sandbox_min_tier: string | null; + created_at: string | null; + updated_at: string | null; + }[]; + meta: object; + }>; + claimWantedItem: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + itemId: string; + direct?: boolean | undefined; + }; + output: { + success: boolean; + }; + meta: object; + }>; + unclaimWantedItem: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + itemId: string; + direct?: boolean | undefined; + }; + output: { + success: boolean; + }; + meta: object; + }>; + postWantedItem: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + title: string; + description: string; + priority?: "critical" | "high" | "low" | "medium" | undefined; + type?: "bug" | "docs" | "feature" | "other" | undefined; + direct?: boolean | undefined; + }; + output: { + success: boolean; + }; + meta: object; + }>; + markWantedItemDone: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + itemId: string; + evidence: string; + direct?: boolean | undefined; + }; + output: { + success: boolean; + }; + meta: object; + }>; + acceptWantedItem: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + itemId: string; + quality: "excellent" | "fair" | "good" | "poor"; + message?: string | undefined; + direct?: boolean | undefined; + }; + output: { + success: boolean; + }; + meta: object; + }>; + rejectWantedItem: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + itemId: string; + reason: string; + direct?: boolean | undefined; + }; + output: { + success: boolean; + }; + meta: object; + }>; + closeWantedItem: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + itemId: string; + direct?: boolean | undefined; + }; + output: { + success: boolean; + }; + meta: object; + }>; + mergeUpstreamPR: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + pullId: string; + }; + output: { + pull_id: string; + state: string; + }; + meta: object; + }>; + closeUpstreamPR: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + pullId: string; + }; + output: { + pull_id: string; + state: string; + }; + meta: object; + }>; + verifyUpstreamAdmin: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + }; + output: { + hasWriteAccess: boolean; + error: string | null; + }; + meta: object; + }>; + listInboxItems: import("@trpc/server").TRPCQueryProcedure<{ + input: { + wastelandId: string; + }; + output: { + items: ({ + pull_id: string; + title: string; + state: string; + from_branch: string | null; + submitter: string | null; + creator_name: string | null; + created_at: string | null; + updated_at: string | null; + kind: "rig-registration"; + handle: string; + display_name: string | null; + dolthub_org: string | null; + owner_email: string | null; + hop_uri: string | null; + gt_version: string | null; + } | { + pull_id: string; + title: string; + state: string; + from_branch: string | null; + submitter: string | null; + creator_name: string | null; + created_at: string | null; + updated_at: string | null; + kind: "wanted-post"; + item_id: string; + item_title: string; + description: string | null; + type: string | null; + priority: string | null; + effort_level: string | null; + tags: string | null; + posted_by: string | null; + } | { + pull_id: string; + title: string; + state: string; + from_branch: string | null; + submitter: string | null; + creator_name: string | null; + created_at: string | null; + updated_at: string | null; + kind: "wanted-edit"; + subkind: "delete" | "unclaim" | "update"; + item_id: string; + item_title: string; + submitter_is_poster: boolean | null; + posted_by: string | null; + status_transition: string | null; + } | { + pull_id: string; + title: string; + state: string; + from_branch: string | null; + submitter: string | null; + creator_name: string | null; + created_at: string | null; + updated_at: string | null; + kind: "work-submission"; + item_id: string; + item_title: string; + claimer: string; + has_done: boolean; + evidence_url: string | null; + completion_id: string | null; + } | { + pull_id: string; + title: string; + state: string; + from_branch: string | null; + submitter: string | null; + creator_name: string | null; + created_at: string | null; + updated_at: string | null; + kind: "admin-action"; + subkind: "accept" | "accept-upstream" | "close" | "close-upstream" | "reject"; + item_id: string; + item_title: string; + worker: string | null; + acceptor: string | null; + reject_reason: string | null; + stamp: { + quality: string | null; + severity: string | null; + skill_tags: string | null; + message: string | null; + } | null; + } | { + pull_id: string; + title: string; + state: string; + from_branch: string | null; + submitter: string | null; + creator_name: string | null; + created_at: string | null; + updated_at: string | null; + kind: "unknown"; + commit_subjects: string[]; + })[]; + }; + meta: object; + }>; + commentOnUpstreamPR: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + pullId: string; + comment: string; + }; + output: { + success: boolean; + }; + meta: object; + }>; + listUpstreamRigs: import("@trpc/server").TRPCQueryProcedure<{ + input: { + wastelandId: string; + }; + output: { + rigs: { + rig_handle: string; + display_name: string | null; + trust_level: number; + registered_at: string | null; + last_seen_at: string | null; + }[]; + }; + meta: object; + }>; + setUpstreamRigTrust: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + rigHandle: string; + trustLevel: number; + }; + output: { + success: boolean; + }; + meta: object; + }>; +}>>; +export type WastelandRouter = typeof wastelandRouter; +/** + * Wrapped router that nests wastelandRouter under a `wasteland` key. + * This preserves the `trpc.wasteland.X` call pattern on the frontend, + * matching the Gastown wrapping convention. + */ +export declare const wrappedWastelandRouter: import("@trpc/server").TRPCBuiltRouter<{ + ctx: TRPCContext; + meta: object; + errorShape: import("@trpc/server").TRPCDefaultErrorShape; + transformer: false; +}, import("@trpc/server").TRPCDecorateCreateRouterOptions<{ + wasteland: import("@trpc/server").TRPCBuiltRouter<{ + ctx: TRPCContext; + meta: object; + errorShape: import("@trpc/server").TRPCDefaultErrorShape; + transformer: false; + }, import("@trpc/server").TRPCDecorateCreateRouterOptions<{ + createWasteland: import("@trpc/server").TRPCMutationProcedure<{ + input: { + name: string; + ownerType: "org" | "user"; + organizationId?: string | undefined; + dolthubUpstream?: string | undefined; + visibility?: "private" | "public" | undefined; + }; + output: { + wasteland_id: string; + name: string; + owner_type: "org" | "user"; + owner_user_id: string | null; + organization_id: string | null; + dolthub_upstream: string | null; + visibility: "private" | "public"; + status: "active" | "deleted"; + created_at: string; + updated_at: string; + }; + meta: object; + }>; + createUpstream: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + upstream: string; + rigHandle?: string | undefined; + rigDisplayName?: string | undefined; + rigEmail?: string | undefined; + }; + output: { + success: boolean; + }; + meta: object; + }>; + listWastelands: import("@trpc/server").TRPCQueryProcedure<{ + input: { + organizationId?: string | undefined; + }; + output: { + wasteland_id: string; + name: string; + owner_type: "org" | "user"; + owner_user_id: string | null; + organization_id: string | null; + dolthub_upstream: string | null; + visibility: "private" | "public"; + status: "active" | "deleted"; + created_at: string; + updated_at: string; + }[]; + meta: object; + }>; + getWasteland: import("@trpc/server").TRPCQueryProcedure<{ + input: { + wastelandId: string; + }; + output: { + wasteland_id: string; + name: string; + owner_type: "org" | "user"; + owner_user_id: string | null; + organization_id: string | null; + dolthub_upstream: string | null; + visibility: "private" | "public"; + status: "active" | "deleted"; + created_at: string; + updated_at: string; + }; + meta: object; + }>; + deleteWasteland: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + }; + output: { + success: boolean; + }; + meta: object; + }>; + adminListWastelands: import("@trpc/server").TRPCQueryProcedure<{ + input: void; + output: { + wasteland_id: string; + name: string; + owner_type: "org" | "user"; + owner_user_id: string | null; + organization_id: string | null; + dolthub_upstream: string | null; + visibility: "private" | "public"; + status: "active" | "deleted"; + created_at: string; + updated_at: string; + }[]; + meta: object; + }>; + listMembers: import("@trpc/server").TRPCQueryProcedure<{ + input: { + wastelandId: string; + }; + output: { + member_id: string; + user_id: string; + trust_level: number; + role: "contributor" | "maintainer" | "owner"; + joined_at: string; + }[]; + meta: object; + }>; + addMember: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + userId: string; + role?: "contributor" | "maintainer" | "owner" | undefined; + trustLevel?: number | undefined; + }; + output: { + member_id: string; + user_id: string; + trust_level: number; + role: "contributor" | "maintainer" | "owner"; + joined_at: string; + }; + meta: object; + }>; + removeMember: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + memberId: string; + }; + output: { + success: boolean; + }; + meta: object; + }>; + updateMember: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + memberId: string; + role?: "contributor" | "maintainer" | "owner" | undefined; + trustLevel?: number | undefined; + }; + output: { + member_id: string; + user_id: string; + trust_level: number; + role: "contributor" | "maintainer" | "owner"; + joined_at: string; + }; + meta: object; + }>; + updateWastelandConfig: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + name?: string | undefined; + visibility?: "private" | "public" | undefined; + dolthubUpstream?: string | undefined; + }; + output: { + wasteland_id: string; + name: string; + owner_type: "org" | "user"; + owner_user_id: string | null; + organization_id: string | null; + dolthub_upstream: string | null; + visibility: "private" | "public"; + status: "active" | "deleted"; + created_at: string; + updated_at: string; + }; + meta: object; + }>; + storeCredential: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + dolthubToken: string; + dolthubOrg: string; + rigHandle?: string | undefined; + doltCredsJwk?: string | undefined; + doltUserName?: string | undefined; + doltUserEmail?: string | undefined; + isUpstreamAdmin?: boolean | undefined; + }; + output: { + user_id: string; + dolthub_org: string; + rig_handle: string | null; + is_upstream_admin: boolean; + connected_at: string; + }; + meta: object; + }>; + getCredentialStatus: import("@trpc/server").TRPCQueryProcedure<{ + input: { + wastelandId: string; + }; + output: { + user_id: string; + dolthub_org: string; + rig_handle: string | null; + is_upstream_admin: boolean; + connected_at: string; + } | null; + meta: object; + }>; + setUpstreamAdmin: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + isUpstreamAdmin: boolean; + }; + output: { + user_id: string; + dolthub_org: string; + rig_handle: string | null; + is_upstream_admin: boolean; + connected_at: string; + } | null; + meta: object; + }>; + deleteCredential: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + }; + output: { + success: boolean; + }; + meta: object; + }>; + containerStatus: import("@trpc/server").TRPCQueryProcedure<{ + input: { + wastelandId: string; + }; + output: { + joined: boolean; + upstream: string | null; + dolthubOrg: string | null; + hasToken: boolean; + hasJwk: boolean; + doltCredPubKey: string | null; + wlVersion: string; + uptime: number; + lastOperation: string | null; + }; + meta: object; + }>; + containerJoin: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + }; + output: { + success: boolean; + }; + meta: object; + }>; + connectKiloTown: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + townId: string; + }; + output: { + town_id: string; + wasteland_id: string; + connected_by: string; + connected_at: string; + }; + meta: object; + }>; + disconnectKiloTown: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + townId: string; + }; + output: { + success: boolean; + }; + meta: object; + }>; + listConnectedTowns: import("@trpc/server").TRPCQueryProcedure<{ + input: { + wastelandId: string; + }; + output: { + town_id: string; + wasteland_id: string; + connected_by: string; + connected_at: string; + }[]; + meta: object; + }>; + browseWantedBoard: import("@trpc/server").TRPCQueryProcedure<{ + input: { + wastelandId: string; + }; + output: { + id: string; + title: string; + description: string | null; + project: string | null; + type: string | null; + priority: string | number | null; + tags: string | null; + posted_by: string | null; + claimed_by: string | null; + status: string; + effort_level: string | null; + evidence_url: string | null; + sandbox_required: string | number | null; + sandbox_scope: string | null; + sandbox_min_tier: string | null; + created_at: string | null; + updated_at: string | null; + }[]; + meta: object; + }>; + claimWantedItem: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + itemId: string; + direct?: boolean | undefined; + }; + output: { + success: boolean; + }; + meta: object; + }>; + unclaimWantedItem: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + itemId: string; + direct?: boolean | undefined; + }; + output: { + success: boolean; + }; + meta: object; + }>; + postWantedItem: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + title: string; + description: string; + priority?: "critical" | "high" | "low" | "medium" | undefined; + type?: "bug" | "docs" | "feature" | "other" | undefined; + direct?: boolean | undefined; + }; + output: { + success: boolean; + }; + meta: object; + }>; + markWantedItemDone: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + itemId: string; + evidence: string; + direct?: boolean | undefined; + }; + output: { + success: boolean; + }; + meta: object; + }>; + acceptWantedItem: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + itemId: string; + quality: "excellent" | "fair" | "good" | "poor"; + message?: string | undefined; + direct?: boolean | undefined; + }; + output: { + success: boolean; + }; + meta: object; + }>; + rejectWantedItem: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + itemId: string; + reason: string; + direct?: boolean | undefined; + }; + output: { + success: boolean; + }; + meta: object; + }>; + closeWantedItem: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + itemId: string; + direct?: boolean | undefined; + }; + output: { + success: boolean; + }; + meta: object; + }>; + mergeUpstreamPR: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + pullId: string; + }; + output: { + pull_id: string; + state: string; + }; + meta: object; + }>; + closeUpstreamPR: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + pullId: string; + }; + output: { + pull_id: string; + state: string; + }; + meta: object; + }>; + verifyUpstreamAdmin: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + }; + output: { + hasWriteAccess: boolean; + error: string | null; + }; + meta: object; + }>; + listInboxItems: import("@trpc/server").TRPCQueryProcedure<{ + input: { + wastelandId: string; + }; + output: { + items: ({ + pull_id: string; + title: string; + state: string; + from_branch: string | null; + submitter: string | null; + creator_name: string | null; + created_at: string | null; + updated_at: string | null; + kind: "rig-registration"; + handle: string; + display_name: string | null; + dolthub_org: string | null; + owner_email: string | null; + hop_uri: string | null; + gt_version: string | null; + } | { + pull_id: string; + title: string; + state: string; + from_branch: string | null; + submitter: string | null; + creator_name: string | null; + created_at: string | null; + updated_at: string | null; + kind: "wanted-post"; + item_id: string; + item_title: string; + description: string | null; + type: string | null; + priority: string | null; + effort_level: string | null; + tags: string | null; + posted_by: string | null; + } | { + pull_id: string; + title: string; + state: string; + from_branch: string | null; + submitter: string | null; + creator_name: string | null; + created_at: string | null; + updated_at: string | null; + kind: "wanted-edit"; + subkind: "delete" | "unclaim" | "update"; + item_id: string; + item_title: string; + submitter_is_poster: boolean | null; + posted_by: string | null; + status_transition: string | null; + } | { + pull_id: string; + title: string; + state: string; + from_branch: string | null; + submitter: string | null; + creator_name: string | null; + created_at: string | null; + updated_at: string | null; + kind: "work-submission"; + item_id: string; + item_title: string; + claimer: string; + has_done: boolean; + evidence_url: string | null; + completion_id: string | null; + } | { + pull_id: string; + title: string; + state: string; + from_branch: string | null; + submitter: string | null; + creator_name: string | null; + created_at: string | null; + updated_at: string | null; + kind: "admin-action"; + subkind: "accept" | "accept-upstream" | "close" | "close-upstream" | "reject"; + item_id: string; + item_title: string; + worker: string | null; + acceptor: string | null; + reject_reason: string | null; + stamp: { + quality: string | null; + severity: string | null; + skill_tags: string | null; + message: string | null; + } | null; + } | { + pull_id: string; + title: string; + state: string; + from_branch: string | null; + submitter: string | null; + creator_name: string | null; + created_at: string | null; + updated_at: string | null; + kind: "unknown"; + commit_subjects: string[]; + })[]; + }; + meta: object; + }>; + commentOnUpstreamPR: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + pullId: string; + comment: string; + }; + output: { + success: boolean; + }; + meta: object; + }>; + listUpstreamRigs: import("@trpc/server").TRPCQueryProcedure<{ + input: { + wastelandId: string; + }; + output: { + rigs: { + rig_handle: string; + display_name: string | null; + trust_level: number; + registered_at: string | null; + last_seen_at: string | null; + }[]; + }; + meta: object; + }>; + setUpstreamRigTrust: import("@trpc/server").TRPCMutationProcedure<{ + input: { + wastelandId: string; + rigHandle: string; + trustLevel: number; + }; + output: { + success: boolean; + }; + meta: object; + }>; + }>>; +}>>; +export type WrappedWastelandRouter = typeof wrappedWastelandRouter; diff --git a/services/wasteland/dist-types/trpc/schemas.d.ts b/services/wasteland/dist-types/trpc/schemas.d.ts new file mode 100644 index 0000000000..a036fcd07c --- /dev/null +++ b/services/wasteland/dist-types/trpc/schemas.d.ts @@ -0,0 +1,473 @@ +import { z } from 'zod'; +export declare const WastelandOutput: z.ZodObject<{ + wasteland_id: z.ZodString; + name: z.ZodString; + owner_type: z.ZodEnum<{ + org: "org"; + user: "user"; + }>; + owner_user_id: z.ZodNullable; + organization_id: z.ZodNullable; + dolthub_upstream: z.ZodNullable; + visibility: z.ZodEnum<{ + private: "private"; + public: "public"; + }>; + status: z.ZodEnum<{ + active: "active"; + deleted: "deleted"; + }>; + created_at: z.ZodString; + updated_at: z.ZodString; +}, z.core.$strip>; +export declare const WastelandMemberOutput: z.ZodObject<{ + member_id: z.ZodString; + user_id: z.ZodString; + trust_level: z.ZodNumber; + role: z.ZodEnum<{ + contributor: "contributor"; + maintainer: "maintainer"; + owner: "owner"; + }>; + joined_at: z.ZodString; +}, z.core.$strip>; +export declare const WastelandCredentialStatusOutput: z.ZodObject<{ + user_id: z.ZodString; + dolthub_org: z.ZodString; + rig_handle: z.ZodNullable; + is_upstream_admin: z.ZodBoolean; + connected_at: z.ZodString; +}, z.core.$strip>; +export declare const WastelandConfigOutput: z.ZodObject<{ + wasteland_id: z.ZodString; + name: z.ZodString; + owner_type: z.ZodEnum<{ + org: "org"; + user: "user"; + }>; + owner_user_id: z.ZodNullable; + organization_id: z.ZodNullable; + dolthub_upstream: z.ZodNullable; + visibility: z.ZodEnum<{ + private: "private"; + public: "public"; + }>; + status: z.ZodEnum<{ + active: "active"; + deleted: "deleted"; + }>; + created_at: z.ZodString; + updated_at: z.ZodString; +}, z.core.$strip>; +export declare const ConnectedTownOutput: z.ZodObject<{ + town_id: z.ZodString; + wasteland_id: z.ZodString; + connected_by: z.ZodString; + connected_at: z.ZodString; +}, z.core.$strip>; +export declare const WantedItemOutput: z.ZodObject<{ + item_id: z.ZodString; + title: z.ZodString; + description: z.ZodString; + status: z.ZodEnum<{ + claimed: "claimed"; + done: "done"; + open: "open"; + }>; + priority: z.ZodEnum<{ + critical: "critical"; + high: "high"; + low: "low"; + medium: "medium"; + }>; + type: z.ZodEnum<{ + bug: "bug"; + docs: "docs"; + feature: "feature"; + other: "other"; + }>; + claimed_by: z.ZodNullable; + evidence: z.ZodNullable; + created_at: z.ZodString; + updated_at: z.ZodString; +}, z.core.$strip>; +export declare const WantedBoardRowOutput: z.ZodObject<{ + id: z.ZodString; + title: z.ZodString; + description: z.ZodDefault>; + project: z.ZodDefault>; + type: z.ZodDefault>; + priority: z.ZodDefault>>; + tags: z.ZodDefault>; + posted_by: z.ZodDefault>; + claimed_by: z.ZodDefault>; + status: z.ZodString; + effort_level: z.ZodDefault>; + evidence_url: z.ZodDefault>; + sandbox_required: z.ZodDefault>>; + sandbox_scope: z.ZodDefault>; + sandbox_min_tier: z.ZodDefault>; + created_at: z.ZodDefault>; + updated_at: z.ZodDefault>; +}, z.core.$strip>; +export declare const MergePullOutput: z.ZodObject<{ + pull_id: z.ZodString; + state: z.ZodString; +}, z.core.$strip>; +export declare const UpstreamAdminVerifyOutput: z.ZodObject<{ + hasWriteAccess: z.ZodBoolean; + error: z.ZodNullable; +}, z.core.$strip>; +export declare const UpstreamRigOutput: z.ZodObject<{ + rig_handle: z.ZodString; + display_name: z.ZodNullable; + trust_level: z.ZodNumber; + registered_at: z.ZodNullable; + last_seen_at: z.ZodNullable; +}, z.core.$strip>; +export declare const InboxItemOutput: z.ZodDiscriminatedUnion<[z.ZodObject<{ + pull_id: z.ZodString; + title: z.ZodString; + state: z.ZodString; + from_branch: z.ZodNullable; + submitter: z.ZodNullable; + creator_name: z.ZodNullable; + created_at: z.ZodNullable; + updated_at: z.ZodNullable; + kind: z.ZodLiteral<"rig-registration">; + handle: z.ZodString; + display_name: z.ZodNullable; + dolthub_org: z.ZodNullable; + owner_email: z.ZodNullable; + hop_uri: z.ZodNullable; + gt_version: z.ZodNullable; +}, z.core.$strip>, z.ZodObject<{ + pull_id: z.ZodString; + title: z.ZodString; + state: z.ZodString; + from_branch: z.ZodNullable; + submitter: z.ZodNullable; + creator_name: z.ZodNullable; + created_at: z.ZodNullable; + updated_at: z.ZodNullable; + kind: z.ZodLiteral<"wanted-post">; + item_id: z.ZodString; + item_title: z.ZodString; + description: z.ZodNullable; + type: z.ZodNullable; + priority: z.ZodNullable; + effort_level: z.ZodNullable; + tags: z.ZodNullable; + posted_by: z.ZodNullable; +}, z.core.$strip>, z.ZodObject<{ + pull_id: z.ZodString; + title: z.ZodString; + state: z.ZodString; + from_branch: z.ZodNullable; + submitter: z.ZodNullable; + creator_name: z.ZodNullable; + created_at: z.ZodNullable; + updated_at: z.ZodNullable; + kind: z.ZodLiteral<"wanted-edit">; + subkind: z.ZodEnum<{ + delete: "delete"; + unclaim: "unclaim"; + update: "update"; + }>; + item_id: z.ZodString; + item_title: z.ZodString; + submitter_is_poster: z.ZodNullable; + posted_by: z.ZodNullable; + status_transition: z.ZodNullable; +}, z.core.$strip>, z.ZodObject<{ + pull_id: z.ZodString; + title: z.ZodString; + state: z.ZodString; + from_branch: z.ZodNullable; + submitter: z.ZodNullable; + creator_name: z.ZodNullable; + created_at: z.ZodNullable; + updated_at: z.ZodNullable; + kind: z.ZodLiteral<"work-submission">; + item_id: z.ZodString; + item_title: z.ZodString; + claimer: z.ZodString; + has_done: z.ZodBoolean; + evidence_url: z.ZodNullable; + completion_id: z.ZodNullable; +}, z.core.$strip>, z.ZodObject<{ + pull_id: z.ZodString; + title: z.ZodString; + state: z.ZodString; + from_branch: z.ZodNullable; + submitter: z.ZodNullable; + creator_name: z.ZodNullable; + created_at: z.ZodNullable; + updated_at: z.ZodNullable; + kind: z.ZodLiteral<"admin-action">; + subkind: z.ZodEnum<{ + accept: "accept"; + "accept-upstream": "accept-upstream"; + close: "close"; + "close-upstream": "close-upstream"; + reject: "reject"; + }>; + item_id: z.ZodString; + item_title: z.ZodString; + worker: z.ZodNullable; + acceptor: z.ZodNullable; + reject_reason: z.ZodNullable; + stamp: z.ZodNullable; + severity: z.ZodNullable; + skill_tags: z.ZodNullable; + message: z.ZodNullable; + }, z.core.$strip>>; +}, z.core.$strip>, z.ZodObject<{ + pull_id: z.ZodString; + title: z.ZodString; + state: z.ZodString; + from_branch: z.ZodNullable; + submitter: z.ZodNullable; + creator_name: z.ZodNullable; + created_at: z.ZodNullable; + updated_at: z.ZodNullable; + kind: z.ZodLiteral<"unknown">; + commit_subjects: z.ZodArray; +}, z.core.$strip>], "kind">; +export declare const RpcWastelandOutput: z.ZodPipe; + owner_user_id: z.ZodNullable; + organization_id: z.ZodNullable; + dolthub_upstream: z.ZodNullable; + visibility: z.ZodEnum<{ + private: "private"; + public: "public"; + }>; + status: z.ZodEnum<{ + active: "active"; + deleted: "deleted"; + }>; + created_at: z.ZodString; + updated_at: z.ZodString; +}, z.core.$strip>>; +export declare const RpcWastelandMemberOutput: z.ZodPipe; + joined_at: z.ZodString; +}, z.core.$strip>>; +export declare const RpcWastelandCredentialStatusOutput: z.ZodPipe; + is_upstream_admin: z.ZodBoolean; + connected_at: z.ZodString; +}, z.core.$strip>>; +export declare const RpcWastelandConfigOutput: z.ZodPipe; + owner_user_id: z.ZodNullable; + organization_id: z.ZodNullable; + dolthub_upstream: z.ZodNullable; + visibility: z.ZodEnum<{ + private: "private"; + public: "public"; + }>; + status: z.ZodEnum<{ + active: "active"; + deleted: "deleted"; + }>; + created_at: z.ZodString; + updated_at: z.ZodString; +}, z.core.$strip>>; +export declare const RpcConnectedTownOutput: z.ZodPipe>; +export declare const RpcWantedItemOutput: z.ZodPipe; + priority: z.ZodEnum<{ + critical: "critical"; + high: "high"; + low: "low"; + medium: "medium"; + }>; + type: z.ZodEnum<{ + bug: "bug"; + docs: "docs"; + feature: "feature"; + other: "other"; + }>; + claimed_by: z.ZodNullable; + evidence: z.ZodNullable; + created_at: z.ZodString; + updated_at: z.ZodString; +}, z.core.$strip>>; +export declare const RpcWantedBoardRowOutput: z.ZodPipe>; + project: z.ZodDefault>; + type: z.ZodDefault>; + priority: z.ZodDefault>>; + tags: z.ZodDefault>; + posted_by: z.ZodDefault>; + claimed_by: z.ZodDefault>; + status: z.ZodString; + effort_level: z.ZodDefault>; + evidence_url: z.ZodDefault>; + sandbox_required: z.ZodDefault>>; + sandbox_scope: z.ZodDefault>; + sandbox_min_tier: z.ZodDefault>; + created_at: z.ZodDefault>; + updated_at: z.ZodDefault>; +}, z.core.$strip>>; +export declare const RpcMergePullOutput: z.ZodPipe>; +export declare const RpcUpstreamAdminVerifyOutput: z.ZodPipe; +}, z.core.$strip>>; +export declare const RpcUpstreamRigOutput: z.ZodPipe; + trust_level: z.ZodNumber; + registered_at: z.ZodNullable; + last_seen_at: z.ZodNullable; +}, z.core.$strip>>; +export declare const RpcInboxItemOutput: z.ZodPipe; + submitter: z.ZodNullable; + creator_name: z.ZodNullable; + created_at: z.ZodNullable; + updated_at: z.ZodNullable; + kind: z.ZodLiteral<"rig-registration">; + handle: z.ZodString; + display_name: z.ZodNullable; + dolthub_org: z.ZodNullable; + owner_email: z.ZodNullable; + hop_uri: z.ZodNullable; + gt_version: z.ZodNullable; +}, z.core.$strip>, z.ZodObject<{ + pull_id: z.ZodString; + title: z.ZodString; + state: z.ZodString; + from_branch: z.ZodNullable; + submitter: z.ZodNullable; + creator_name: z.ZodNullable; + created_at: z.ZodNullable; + updated_at: z.ZodNullable; + kind: z.ZodLiteral<"wanted-post">; + item_id: z.ZodString; + item_title: z.ZodString; + description: z.ZodNullable; + type: z.ZodNullable; + priority: z.ZodNullable; + effort_level: z.ZodNullable; + tags: z.ZodNullable; + posted_by: z.ZodNullable; +}, z.core.$strip>, z.ZodObject<{ + pull_id: z.ZodString; + title: z.ZodString; + state: z.ZodString; + from_branch: z.ZodNullable; + submitter: z.ZodNullable; + creator_name: z.ZodNullable; + created_at: z.ZodNullable; + updated_at: z.ZodNullable; + kind: z.ZodLiteral<"wanted-edit">; + subkind: z.ZodEnum<{ + delete: "delete"; + unclaim: "unclaim"; + update: "update"; + }>; + item_id: z.ZodString; + item_title: z.ZodString; + submitter_is_poster: z.ZodNullable; + posted_by: z.ZodNullable; + status_transition: z.ZodNullable; +}, z.core.$strip>, z.ZodObject<{ + pull_id: z.ZodString; + title: z.ZodString; + state: z.ZodString; + from_branch: z.ZodNullable; + submitter: z.ZodNullable; + creator_name: z.ZodNullable; + created_at: z.ZodNullable; + updated_at: z.ZodNullable; + kind: z.ZodLiteral<"work-submission">; + item_id: z.ZodString; + item_title: z.ZodString; + claimer: z.ZodString; + has_done: z.ZodBoolean; + evidence_url: z.ZodNullable; + completion_id: z.ZodNullable; +}, z.core.$strip>, z.ZodObject<{ + pull_id: z.ZodString; + title: z.ZodString; + state: z.ZodString; + from_branch: z.ZodNullable; + submitter: z.ZodNullable; + creator_name: z.ZodNullable; + created_at: z.ZodNullable; + updated_at: z.ZodNullable; + kind: z.ZodLiteral<"admin-action">; + subkind: z.ZodEnum<{ + accept: "accept"; + "accept-upstream": "accept-upstream"; + close: "close"; + "close-upstream": "close-upstream"; + reject: "reject"; + }>; + item_id: z.ZodString; + item_title: z.ZodString; + worker: z.ZodNullable; + acceptor: z.ZodNullable; + reject_reason: z.ZodNullable; + stamp: z.ZodNullable; + severity: z.ZodNullable; + skill_tags: z.ZodNullable; + message: z.ZodNullable; + }, z.core.$strip>>; +}, z.core.$strip>, z.ZodObject<{ + pull_id: z.ZodString; + title: z.ZodString; + state: z.ZodString; + from_branch: z.ZodNullable; + submitter: z.ZodNullable; + creator_name: z.ZodNullable; + created_at: z.ZodNullable; + updated_at: z.ZodNullable; + kind: z.ZodLiteral<"unknown">; + commit_subjects: z.ZodArray; +}, z.core.$strip>], "kind">>; diff --git a/services/wasteland/dist-types/util/analytics.util.d.ts b/services/wasteland/dist-types/util/analytics.util.d.ts new file mode 100644 index 0000000000..6957680784 --- /dev/null +++ b/services/wasteland/dist-types/util/analytics.util.d.ts @@ -0,0 +1,25 @@ +/** + * Controller-level event names emitted from HTTP handlers. + * Internal DO events use string literals directly. + */ +export type WastelandEventName = 'wasteland.created' | 'wasteland.deleted' | 'credential.stored' | 'credential.deleted' | 'member.added' | 'member.removed' | 'wanted.browse' | 'wanted.claim' | 'wanted.done' | 'wanted.post' | 'wanted.sync' | (string & {}); +export type WastelandDelivery = 'http' | 'trpc' | 'internal' | 'billing'; +export type WastelandEventData = { + event: WastelandEventName; + delivery?: WastelandDelivery; + route?: string; + error?: string; + userId?: string; + wastelandId?: string; + memberId?: string; + durationMs?: number; + value?: number; + label?: string; +}; +/** + * Write a single event to Cloudflare Analytics Engine. + * Safe to call in development (where the binding is absent) — silently no-ops. + */ +export declare function writeEvent(env: { + WASTELAND_AE?: AnalyticsEngineDataset; +}, data: WastelandEventData): void; diff --git a/services/wasteland/dist-types/util/billing.util.d.ts b/services/wasteland/dist-types/util/billing.util.d.ts new file mode 100644 index 0000000000..9a0282b461 --- /dev/null +++ b/services/wasteland/dist-types/util/billing.util.d.ts @@ -0,0 +1,29 @@ +export type BillableEvent = 'billing.wasteland_created' | 'billing.wasteland_deleted' | 'billing.api_operation' | 'billing.credential_stored' | 'billing.credential_deleted' | 'billing.member_added' | 'billing.member_removed'; +/** + * Categorises the API operation for metering granularity. + * Write operations (mutations that modify DoltHub state) are metered + * individually; reads are not metered today. + */ +export type BillingOperationKind = 'claim' | 'done' | 'post' | 'config_update' | 'member_update'; +type BillingEnv = { + WASTELAND_AE?: AnalyticsEngineDataset; +}; +type MeterEventInput = { + event: BillableEvent; + userId: string; + wastelandId: string; + /** Free-form label for sub-categorisation (e.g. operation kind). */ + label?: string; + /** Numeric value associated with the event (e.g. member count). */ + value?: number; +}; +/** + * Record a billable event in the Analytics Engine. + * + * Uses `delivery: 'billing'` so billing-specific queries can filter on + * the delivery channel without scanning the full event stream. + * + * Best-effort — never throws. + */ +export declare function meterEvent(env: BillingEnv, input: MeterEventInput): void; +export {}; diff --git a/services/wasteland/dist-types/util/crypto.util.d.ts b/services/wasteland/dist-types/util/crypto.util.d.ts new file mode 100644 index 0000000000..083c6ae8a5 --- /dev/null +++ b/services/wasteland/dist-types/util/crypto.util.d.ts @@ -0,0 +1,6 @@ +/** Encrypt a plaintext string. Returns base64(iv || ciphertext || tag). */ +export declare function encryptToken(plaintext: string, key: CryptoKey): Promise; +/** Decrypt a base64(iv || ciphertext || tag) string back to plaintext. */ +export declare function decryptToken(encrypted: string, key: CryptoKey): Promise; +/** Derive an AES-256-GCM CryptoKey from a secret string using PBKDF2. */ +export declare function deriveEncryptionKey(secret: string): Promise; diff --git a/services/wasteland/dist-types/util/dolthub-api.util.d.ts b/services/wasteland/dist-types/util/dolthub-api.util.d.ts new file mode 100644 index 0000000000..2c7b1ff896 --- /dev/null +++ b/services/wasteland/dist-types/util/dolthub-api.util.d.ts @@ -0,0 +1,99 @@ +/** + * Thin client for the DoltHub REST API — used by admin-mode tRPC procedures + * to list, merge, and close pull requests on an upstream repo. + * + * Callers pass a token explicitly; this module never reads from secrets. + * All responses are validated with Zod before being returned. + */ +import { z } from 'zod'; +export declare const DOLTHUB_API_BASE = "https://www.dolthub.com/api/v1alpha1"; +export declare class DoltHubApiError extends Error { + readonly status: number; + constructor(message: string, status: number); +} +/** + * Parse a DoltHub upstream string (e.g. "hop/wl-commons") into owner + db. + */ +export declare function parseUpstream(upstream: string): { + owner: string; + db: string; +}; +export declare const DoltHubPull: z.ZodObject<{ + pull_id: z.ZodPipe, z.ZodTransform>; + title: z.ZodDefault; + description: z.ZodDefault>; + state: z.ZodString; + created_at: z.ZodDefault>; + updated_at: z.ZodDefault>; + creator_name: z.ZodDefault>; +}, z.core.$loose>; +export type DoltHubPullT = z.infer; +/** + * List pull requests on the upstream repo, optionally filtered by state + * ("Open" | "Closed" | "Merged"). The DoltHub API ignores the `state` query + * parameter server-side, so we always fetch all and filter client-side. + */ +export declare function listPulls(upstream: string, token: string, opts?: { + state?: 'Open' | 'Closed' | 'Merged'; +}): Promise; +export declare const DoltHubPullDetail: z.ZodObject<{ + pull_id: z.ZodPipe, z.ZodTransform>; + title: z.ZodDefault; + description: z.ZodDefault>; + state: z.ZodString; + from_branch_name: z.ZodDefault>; + to_branch_name: z.ZodDefault>; + from_branch_owner_name: z.ZodDefault>; + from_branch_repo_name: z.ZodDefault>; + creator_name: z.ZodDefault>; + created_at: z.ZodDefault>; + updated_at: z.ZodDefault>; +}, z.core.$loose>; +export type DoltHubPullDetailT = z.infer; +export declare function getPull(upstream: string, token: string, pullId: string): Promise; +export declare function mergePull(upstream: string, token: string, pullId: string): Promise<{ + state: string; +}>; +export declare function closePull(upstream: string, token: string, pullId: string): Promise<{ + state: string; +}>; +/** + * Post a comment on an upstream pull request. DoltHub supports POSTing + * comments but does not expose a GET endpoint for reading them via REST, + * so the UI links out for viewing and uses this for posting only. + */ +export declare function commentOnPull(upstream: string, token: string, pullId: string, comment: string): Promise; +declare const SqlResponse: z.ZodObject<{ + query_execution_status: z.ZodOptional; + query_execution_message: z.ZodOptional; + rows: z.ZodOptional>>; +}, z.core.$loose>; +export type DoltHubSqlResultT = z.infer; +export declare function runSql(upstream: string, token: string, branch: string, sql: string): Promise; +/** + * Write API — creates `toBranch` forked from `fromBranch` and commits the + * DML in one call. Used for admin operations like rig trust-level edits. + */ +export declare function runWrite(upstream: string, token: string, fromBranch: string, toBranch: string, sql: string): Promise; +/** + * `wl` creates one PR per contribution with branch name `wl/{rig-handle}/{item-id}`. + * Parse the branch name back out to associate a PR with a wanted item. + */ +export declare function parseWlBranch(branch: string | null): { + rigHandle: string; + itemId: string; +} | null; +/** + * Delete a branch on the upstream. Used to clean up scratch branches + * created by admin probes and direct writes. Failures are swallowed — + * the caller wants best-effort cleanup, not to fail the parent op. + */ +export declare function deleteBranch(upstream: string, token: string, branch: string): Promise; +/** + * Map with a bounded concurrency pool. Useful for batch DoltHub calls + * (e.g. fetching detail for N pull requests) where `Promise.all` on the + * whole list would hammer the API and blow past Cloudflare's subrequest + * budget. + */ +export declare function mapWithLimit(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise): Promise; +export {}; diff --git a/services/wasteland/dist-types/util/log.util.d.ts b/services/wasteland/dist-types/util/log.util.d.ts new file mode 100644 index 0000000000..daaac1c79e --- /dev/null +++ b/services/wasteland/dist-types/util/log.util.d.ts @@ -0,0 +1,25 @@ +/** + * Structured logging powered by workers-tagged-logger. + * + * Uses AsyncLocalStorage so tags (wastelandId, userId, etc.) propagate + * to all downstream functions without explicit parameter passing. + * + * Setup: + * - In the Hono worker: use `useWorkersLogger` middleware to establish context. + * - In DOs: wrap the entry point (alarm, RPC) with `withLogTags`. + * - Anywhere: call `logger.setTags({ wastelandId })` to tag all subsequent logs. + * + * Usage: + * import { logger } from '../util/log.util'; + * logger.info('initializeWasteland: stored config'); + * logger.warn('storeCredential: missing token', { userId }); + */ +import { WorkersLogger, withLogTags } from 'workers-tagged-logger'; +export type LogTags = { + source?: string; + wastelandId?: string; + userId?: string; + memberId?: string; +}; +export declare const logger: WorkersLogger; +export { withLogTags }; diff --git a/services/wasteland/dist-types/util/query.util.d.ts b/services/wasteland/dist-types/util/query.util.d.ts new file mode 100644 index 0000000000..75c31902eb --- /dev/null +++ b/services/wasteland/dist-types/util/query.util.d.ts @@ -0,0 +1,13 @@ +/** + * CountOccurrences type counts the number of times a SubString appears in a String_. + * Uses a recursive approach with a counter represented as an array of unknown. + */ +type CountOccurrences = String_ extends `${string}${SubString}${infer Tail}` ? CountOccurrences : Count['length']; +type Tuple = Acc['length'] extends N ? Acc : Tuple; +export type SqliteParams = Tuple>; +/** + * Type-safe SQLite query helper. The params tuple length is statically + * checked against the number of `?` placeholders in the query string. + */ +export declare function query(sql: SqlStorage, query: Query, params: SqliteParams & unknown[]): SqlStorageCursor>; +export {}; diff --git a/services/wasteland/dist-types/util/rate-limit.util.d.ts b/services/wasteland/dist-types/util/rate-limit.util.d.ts new file mode 100644 index 0000000000..9dd4dea696 --- /dev/null +++ b/services/wasteland/dist-types/util/rate-limit.util.d.ts @@ -0,0 +1,17 @@ +type RateLimitConfig = { + /** Maximum number of requests allowed within the window. */ + maxRequests: number; + /** Time window in milliseconds. */ + windowMs: number; +}; +/** Per-operation rate limit configs. */ +export declare const RATE_LIMITS: Record; +/** + * Check whether the request should be allowed under the rate limit. + * Throws a TRPCError with code TOO_MANY_REQUESTS if the limit is exceeded. + * + * If no rate limit is configured for the given operation, the request is + * always allowed. + */ +export declare function checkRateLimit(userId: string, operation: string): void; +export {}; diff --git a/services/wasteland/dist-types/util/res.util.d.ts b/services/wasteland/dist-types/util/res.util.d.ts new file mode 100644 index 0000000000..13de87ffc1 --- /dev/null +++ b/services/wasteland/dist-types/util/res.util.d.ts @@ -0,0 +1 @@ +export { resSuccess, resError, type SuccessResponse, type ErrorResponse, } from '@kilocode/worker-utils'; diff --git a/services/wasteland/dist-types/util/secret.util.d.ts b/services/wasteland/dist-types/util/secret.util.d.ts new file mode 100644 index 0000000000..15aa263873 --- /dev/null +++ b/services/wasteland/dist-types/util/secret.util.d.ts @@ -0,0 +1,9 @@ +/** + * Resolves a secret value from either a `SecretsStoreSecret` (production, has `.get()`) + * or a plain string (test env vars set in wrangler.test.jsonc). + * + * Returns `null` when the Secrets Store fetch fails (transient network error, + * misconfigured store, etc.) so callers can return a clean 500 instead of + * letting an opaque "Secrets Worker: Failed to fetch secret" bubble up. + */ +export declare function resolveSecret(binding: SecretsStoreSecret | string): Promise; diff --git a/services/wasteland/dist-types/util/table.d.ts b/services/wasteland/dist-types/util/table.d.ts new file mode 100644 index 0000000000..ea446801e6 --- /dev/null +++ b/services/wasteland/dist-types/util/table.d.ts @@ -0,0 +1,28 @@ +import type { z } from 'zod'; +export type TableInput = { + name: string; + columns: readonly string[]; +}; +export type TableQueryInterpolator = { + _name: T['name']; + columns: { + [K in T['columns'][number]]: K; + }; + valueOf: () => T['name']; + toString: () => T['name']; +} & { + [K in T['columns'][number]]: `${T['name']}.${K}`; +}; +export declare function getTable(table: T): TableQueryInterpolator; +export declare function getTableFromZodSchema>(name: Name, schema: Schema): TableQueryInterpolator<{ + name: Name; + columns: Array, string>>; +}>; +export type BaseTableQueryInterpolator = TableQueryInterpolator<{ + name: string; + columns: []; +}>; +export type TableSqliteTypeMap = { + [K in keyof T['columns']]: string; +}; +export declare function getCreateTableQueryFromTable(table: T, columnTypeMap: TableSqliteTypeMap): string; diff --git a/services/wasteland/dist-types/wanted-board/wanted-board-ops.d.ts b/services/wasteland/dist-types/wanted-board/wanted-board-ops.d.ts new file mode 100644 index 0000000000..28ef188b16 --- /dev/null +++ b/services/wasteland/dist-types/wanted-board/wanted-board-ops.d.ts @@ -0,0 +1,84 @@ +/** + * Wanted board operations — shared business logic used by both the tRPC + * router and the WastelandRPCEntrypoint. Each function owns the full + * operation: credential decryption, container dispatch, result parsing, + * cache refresh, and metering. + * + * All ownership/auth checks happen in the callers (tRPC via + * resolveWastelandOwnership, RPC via the fact that only peer workers + * can call the binding). + */ +import { z } from 'zod'; +export declare class WantedBoardOpError extends Error { + /** Maps roughly to HTTP/tRPC codes. Callers translate as needed. */ + readonly code: 'NOT_FOUND' | 'PRECONDITION_FAILED' | 'INTERNAL_SERVER_ERROR' | 'UPSTREAM_ERROR'; + constructor(message: string, + /** Maps roughly to HTTP/tRPC codes. Callers translate as needed. */ + code: 'NOT_FOUND' | 'PRECONDITION_FAILED' | 'INTERNAL_SERVER_ERROR' | 'UPSTREAM_ERROR'); +} +declare const PriorityEnum: z.ZodEnum<{ + critical: "critical"; + high: "high"; + low: "low"; + medium: "medium"; +}>; +declare const TypeEnum: z.ZodEnum<{ + bug: "bug"; + docs: "docs"; + feature: "feature"; + other: "other"; +}>; +export declare function browseWantedBoard(env: Env, wastelandId: string, userId: string): Promise>>; +export declare function claimWantedItem(env: Env, wastelandId: string, userId: string, itemId: string, options?: { + direct?: boolean; +}): Promise<{ + success: true; +}>; +export declare function unclaimWantedItem(env: Env, wastelandId: string, userId: string, itemId: string, options?: { + direct?: boolean; +}): Promise<{ + success: true; +}>; +export declare function acceptWantedItem(env: Env, wastelandId: string, userId: string, input: { + itemId: string; + quality: 'excellent' | 'good' | 'fair' | 'poor'; + /** Free-form message attached to the stamp (written to `stamps.message`). */ + message?: string; + direct?: boolean; +}): Promise<{ + success: true; +}>; +export declare function rejectWantedItem(env: Env, wastelandId: string, userId: string, input: { + itemId: string; + /** + * Rejection reason — becomes part of the `wl reject` commit message. + * Maps to `--reason` on the wl CLI (not `--comment`, which is an + * approve/request-changes flag). + */ + reason: string; + direct?: boolean; +}): Promise<{ + success: true; +}>; +export declare function closeWantedItem(env: Env, wastelandId: string, userId: string, itemId: string, options?: { + direct?: boolean; +}): Promise<{ + success: true; +}>; +export declare function postWantedItem(env: Env, wastelandId: string, userId: string, input: { + title: string; + description: string; + priority?: z.infer; + type?: z.infer; + direct?: boolean; +}): Promise<{ + success: true; +}>; +export declare function markWantedItemDone(env: Env, wastelandId: string, userId: string, input: { + itemId: string; + evidence: string; + direct?: boolean; +}): Promise<{ + success: true; +}>; +export {}; diff --git a/services/wasteland/dist-types/wasteland-rpc.entrypoint.d.ts b/services/wasteland/dist-types/wasteland-rpc.entrypoint.d.ts new file mode 100644 index 0000000000..ea2948e3e6 --- /dev/null +++ b/services/wasteland/dist-types/wasteland-rpc.entrypoint.d.ts @@ -0,0 +1,93 @@ +/** + * RPC entrypoint for peer workers (e.g. gastown) to call wasteland + * operations directly without going through HTTP + tRPC. + * + * Exposed as `WastelandRPCEntrypoint` in wrangler.jsonc and bound by + * consumers via `services` bindings with `entrypoint: "WastelandRPCEntrypoint"`. + * + * Auth model: the caller is a trusted peer worker. The userId is passed + * as a parameter and used for credential lookup, metering, and audit — + * but we do NOT re-validate it here because peer workers have already + * authenticated their inbound user. + */ +import { WorkerEntrypoint } from 'cloudflare:workers'; +import { WantedBoardOpError } from './wanted-board/wanted-board-ops'; +export type WastelandRpcResult = { + success: true; + data: T; +} | { + success: false; + code: WantedBoardOpError['code']; + message: string; +}; +export declare class WastelandRPCEntrypoint extends WorkerEntrypoint { + browseWantedBoard(params: { + wastelandId: string; + userId: string; + }): Promise[]>>; + claimWantedItem(params: { + wastelandId: string; + userId: string; + itemId: string; + direct?: boolean; + }): Promise>; + unclaimWantedItem(params: { + wastelandId: string; + userId: string; + itemId: string; + direct?: boolean; + }): Promise>; + postWantedItem(params: { + wastelandId: string; + userId: string; + title: string; + description: string; + priority?: 'low' | 'medium' | 'high' | 'critical'; + type?: 'feature' | 'bug' | 'docs' | 'other'; + direct?: boolean; + }): Promise>; + markWantedItemDone(params: { + wastelandId: string; + userId: string; + itemId: string; + evidence: string; + direct?: boolean; + }): Promise>; + acceptWantedItem(params: { + wastelandId: string; + userId: string; + itemId: string; + quality: 'excellent' | 'good' | 'fair' | 'poor'; + /** Free-form message attached to the reputation stamp. */ + message?: string; + direct?: boolean; + }): Promise>; + rejectWantedItem(params: { + wastelandId: string; + userId: string; + itemId: string; + /** Rejection reason (maps to `wl reject --reason`). */ + reason: string; + direct?: boolean; + }): Promise>; + closeWantedItem(params: { + wastelandId: string; + userId: string; + itemId: string; + direct?: boolean; + }): Promise>; +} diff --git a/services/wasteland/dist-types/wasteland.worker.d.ts b/services/wasteland/dist-types/wasteland.worker.d.ts new file mode 100644 index 0000000000..404cc4e707 --- /dev/null +++ b/services/wasteland/dist-types/wasteland.worker.d.ts @@ -0,0 +1,11 @@ +import type { AuthVariables } from './middleware/auth.middleware'; +export { WastelandDO } from './dos/Wasteland.do'; +export { WastelandContainerDO } from './dos/WastelandContainer.do'; +export { WastelandRegistryDO } from './dos/WastelandRegistry.do'; +export { WastelandRPCEntrypoint } from './wasteland-rpc.entrypoint'; +export type WastelandEnv = { + Bindings: Env; + Variables: AuthVariables; +}; +declare const _default: ExportedHandler; +export default _default; From 1b30c7b29f89d360dfe4911f49ec0f581f3887d6 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 15 Apr 2026 19:16:59 +0100 Subject: [PATCH 14/33] fix(gastown): revert pinned container image to local Dockerfile ref --- services/gastown/wrangler.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/gastown/wrangler.jsonc b/services/gastown/wrangler.jsonc index 649919cee1..28fa5fce54 100644 --- a/services/gastown/wrangler.jsonc +++ b/services/gastown/wrangler.jsonc @@ -35,7 +35,7 @@ "containers": [ { "class_name": "TownContainerDO", - "image": "registry.cloudflare.com/e115e769bcdd4c3d66af59d3332cb394/gastown-towncontainerdo:197958b7", + "image": "./container/Dockerfile.dev", "instance_type": "standard-4", "max_instances": 810, }, From 70333318baff6849258b0997a768b6295f693413 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 15 Apr 2026 20:26:29 +0100 Subject: [PATCH 15/33] chore(gastown): pin @kilocode/cli to 7.2.7 in container Dockerfiles The CLI was installed unpinned via npm install -g, so containers got whatever version was latest at image build time. Pin all CLI packages and the global @kilocode/plugin to 7.2.7 to match the SDK. --- services/gastown/container/Dockerfile | 2 +- services/gastown/container/Dockerfile.dev | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/services/gastown/container/Dockerfile b/services/gastown/container/Dockerfile index 44afc48b6d..3b46e4e8f9 100644 --- a/services/gastown/container/Dockerfile +++ b/services/gastown/container/Dockerfile @@ -72,7 +72,7 @@ RUN git lfs install --system # Install both glibc and musl variants — the CLI's binary resolver may # pick either depending on the detected libc. # Also install pnpm — many projects use it as their package manager. -RUN npm install -g @kilocode/cli @kilocode/cli-linux-x64 @kilocode/cli-linux-x64-musl @kilocode/plugin pnpm && \ +RUN npm install -g @kilocode/cli@7.2.7 @kilocode/cli-linux-x64@7.2.7 @kilocode/cli-linux-x64-musl@7.2.7 @kilocode/plugin@7.2.7 pnpm && \ ln -s "$(which kilo)" /usr/local/bin/opencode # Create non-root user for defense-in-depth diff --git a/services/gastown/container/Dockerfile.dev b/services/gastown/container/Dockerfile.dev index 5e5f00ace4..2ad4746cc7 100644 --- a/services/gastown/container/Dockerfile.dev +++ b/services/gastown/container/Dockerfile.dev @@ -71,7 +71,7 @@ RUN git lfs install --system # pick either depending on the detected libc. bun:1-slim is Debian (glibc) # but the resolver sometimes misdetects; installing both is safe. # Also install pnpm — many projects use it as their package manager. -RUN npm install -g @kilocode/cli @kilocode/cli-linux-arm64 @kilocode/cli-linux-arm64-musl pnpm && \ +RUN npm install -g @kilocode/cli@7.2.7 @kilocode/cli-linux-arm64@7.2.7 @kilocode/cli-linux-arm64-musl@7.2.7 @kilocode/plugin@7.2.7 pnpm && \ ln -s "$(which kilo)" /usr/local/bin/opencode # Create non-root user for defense-in-depth From 307facffaadedbdeb7730018fce2829456e02226 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 15 Apr 2026 22:41:08 +0100 Subject: [PATCH 16/33] fix(gastown): exclude landing MR beads from orphan cleanup; allow failed convoy state The reconcileReviewQueue orphan cleanup query matched system-created landing MR beads (review-then-land convoys) because it only checked parent_bead_id for convoy membership, but landing MRs link via bead_dependencies. This caused landing MRs to be immediately failed with 'no pr_url' on every creation attempt, exhausting all 5 retries and failing the convoy. Three fixes: 1. Orphan cleanup: add created_by != 'system' filter so landing MR beads (always created_by='system') are excluded 2. Refinery dispatch: when code_review=false, also dispatch for system-created MR beads so the refinery picks up landing MRs 3. Invariant 5: add 'failed' to valid convoy states so FailConvoy doesn't trigger continuous invariant violations every 5s tick --- services/gastown/src/dos/town/reconciler.ts | 42 +++++++++++++-------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/services/gastown/src/dos/town/reconciler.ts b/services/gastown/src/dos/town/reconciler.ts index 91884509e5..75ee36a483 100644 --- a/services/gastown/src/dos/town/reconciler.ts +++ b/services/gastown/src/dos/town/reconciler.ts @@ -1311,9 +1311,14 @@ export function reconcileReviewQueue( } // Orphan cleanup: open MR beads without pr_url that aren't convoy - // review-and-merge beads. The polecat should have created the PR - // (merge_strategy=pr) but didn't — fail the MR and reopen the - // source bead so another polecat can retry. + // review-and-merge beads or system-created landing MR beads. + // The polecat should have created the PR (merge_strategy=pr) but + // didn't — fail the MR and reopen the source bead so another + // polecat can retry. + // Landing MR beads (created_by='system') are excluded because they + // are created by reconcileConvoys for review-then-land convoys and + // intentionally have no pr_url at creation — the refinery creates + // the PR when it picks up the landing MR. const orphanedMrs = z .object({ bead_id: z.string(), source_bead_id: z.string().nullable() }) .array() @@ -1333,6 +1338,7 @@ export function reconcileReviewQueue( AND b.${beads.columns.status} = 'open' AND b.${beads.columns.rig_id} = ? AND rm.${review_metadata.columns.pr_url} IS NULL + AND b.${beads.columns.created_by} != 'system' AND NOT EXISTS ( SELECT 1 FROM ${beads} parent @@ -1395,21 +1401,25 @@ export function reconcileReviewQueue( ]); for (const { rig_id } of rigsWithOpenMrs) { - // When code_review=false, only dispatch the refinery for convoy - // review-and-merge MR beads (refinery does combined review+merge). + // When code_review=false, only dispatch the refinery for: + // 1. Convoy review-and-merge MR beads (refinery does combined review+merge) + // 2. System-created landing MR beads (review-then-land convoy finalization) // MR beads WITH a pr_url are handled by the fast-track → poll_pr. // MR beads WITHOUT a pr_url when merge_strategy=pr are orphaned - // (polecat should have created the PR) — Rule 2 handles them. + // (polecat should have created the PR) — orphan cleanup handles them. const refineryNeededFilter = rigCodeReview(rig_id) ? '' : /* sql */ ` - AND EXISTS ( - SELECT 1 - FROM ${beads} outer_parent - JOIN ${convoy_metadata} cm - ON cm.${convoy_metadata.columns.bead_id} = outer_parent.${beads.columns.bead_id} - WHERE outer_parent.${beads.columns.bead_id} = ${beads.parent_bead_id} - AND cm.${convoy_metadata.columns.merge_mode} = 'review-and-merge' + AND ( + EXISTS ( + SELECT 1 + FROM ${beads} outer_parent + JOIN ${convoy_metadata} cm + ON cm.${convoy_metadata.columns.bead_id} = outer_parent.${beads.columns.bead_id} + WHERE outer_parent.${beads.columns.bead_id} = ${beads.parent_bead_id} + AND cm.${convoy_metadata.columns.merge_mode} = 'review-and-merge' + ) + OR ${beads.created_by} = 'system' )`; // Check if rig already has an in_progress MR that needs the refinery. @@ -2305,7 +2315,9 @@ export function checkInvariants(sql: SqlStorage): Violation[] { } // Invariant 5: Convoy beads should not be in unexpected states. - // Valid transient states: open, in_progress, in_review, closed. + // Valid states: open, in_progress, in_review, closed, failed. + // 'failed' is a terminal state set by FailConvoy when landing MR + // creation is exhausted. const badStateConvoys = z .object({ bead_id: z.string(), status: z.string() }) .array() @@ -2316,7 +2328,7 @@ export function checkInvariants(sql: SqlStorage): Violation[] { SELECT ${beads.bead_id}, ${beads.status} FROM ${beads} WHERE ${beads.type} = 'convoy' - AND ${beads.status} NOT IN ('open', 'in_progress', 'in_review', 'closed') + AND ${beads.status} NOT IN ('open', 'in_progress', 'in_review', 'closed', 'failed') `, [] ), From 562eeacc5c06895922724b6822226a8b829d3de7 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Fri, 17 Apr 2026 15:24:45 -0500 Subject: [PATCH 17/33] chore(gastown): update @kilocode/sdk, plugin, and CLI to 7.2.14 --- services/gastown/container/Dockerfile | 2 +- services/gastown/container/Dockerfile.dev | 2 +- services/gastown/container/package.json | 4 ++-- services/gastown/container/plugin/package.json | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/services/gastown/container/Dockerfile b/services/gastown/container/Dockerfile index 3b46e4e8f9..a0db8ddc00 100644 --- a/services/gastown/container/Dockerfile +++ b/services/gastown/container/Dockerfile @@ -72,7 +72,7 @@ RUN git lfs install --system # Install both glibc and musl variants — the CLI's binary resolver may # pick either depending on the detected libc. # Also install pnpm — many projects use it as their package manager. -RUN npm install -g @kilocode/cli@7.2.7 @kilocode/cli-linux-x64@7.2.7 @kilocode/cli-linux-x64-musl@7.2.7 @kilocode/plugin@7.2.7 pnpm && \ +RUN npm install -g @kilocode/cli@7.2.14 @kilocode/cli-linux-x64@7.2.14 @kilocode/cli-linux-x64-musl@7.2.14 @kilocode/plugin@7.2.14 pnpm && \ ln -s "$(which kilo)" /usr/local/bin/opencode # Create non-root user for defense-in-depth diff --git a/services/gastown/container/Dockerfile.dev b/services/gastown/container/Dockerfile.dev index 2ad4746cc7..0b5ecf53ff 100644 --- a/services/gastown/container/Dockerfile.dev +++ b/services/gastown/container/Dockerfile.dev @@ -71,7 +71,7 @@ RUN git lfs install --system # pick either depending on the detected libc. bun:1-slim is Debian (glibc) # but the resolver sometimes misdetects; installing both is safe. # Also install pnpm — many projects use it as their package manager. -RUN npm install -g @kilocode/cli@7.2.7 @kilocode/cli-linux-arm64@7.2.7 @kilocode/cli-linux-arm64-musl@7.2.7 @kilocode/plugin@7.2.7 pnpm && \ +RUN npm install -g @kilocode/cli@7.2.14 @kilocode/cli-linux-arm64@7.2.14 @kilocode/cli-linux-arm64-musl@7.2.14 @kilocode/plugin@7.2.14 pnpm && \ ln -s "$(which kilo)" /usr/local/bin/opencode # Create non-root user for defense-in-depth diff --git a/services/gastown/container/package.json b/services/gastown/container/package.json index dfb8424531..197a4eb38d 100644 --- a/services/gastown/container/package.json +++ b/services/gastown/container/package.json @@ -12,8 +12,8 @@ "lint": "pnpm -w exec oxlint --config .oxlintrc.json services/gastown/container/src" }, "dependencies": { - "@kilocode/plugin": "7.2.7", - "@kilocode/sdk": "7.2.7", + "@kilocode/plugin": "7.2.14", + "@kilocode/sdk": "7.2.14", "hono": "catalog:", "zod": "catalog:" }, diff --git a/services/gastown/container/plugin/package.json b/services/gastown/container/plugin/package.json index 9c404d57a1..9c89e81375 100644 --- a/services/gastown/container/plugin/package.json +++ b/services/gastown/container/plugin/package.json @@ -6,8 +6,8 @@ "description": "Kilo plugin exposing Gastown tools to agents", "main": "index.ts", "dependencies": { - "@kilocode/plugin": "7.2.7", - "@kilocode/sdk": "7.2.7", + "@kilocode/plugin": "7.2.14", + "@kilocode/sdk": "7.2.14", "zod": "^4.3.5" } } From e48aad6d1eaa1810a8f2b6b3c705603015d144e4 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 18 Apr 2026 03:23:40 +0100 Subject: [PATCH 18/33] feat(gastown): auto-resolve merge conflicts on PRs (#2427) (#2484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gastown): add auto_resolve_merge_conflicts setting to schemas and UI (#2473) - Add auto_resolve_merge_conflicts to TownConfigSchema refinery sub-object (default: true) - Add auto_resolve_merge_conflicts to RigOverrideConfigSchema - Add auto_resolve_merge_conflicts to TownConfigUpdateSchema - Wire auto_resolve_merge_conflicts into EffectiveConfig and resolveRigConfig() - Wire into updateTownConfig() refinery merge path - Add toggle to town settings Refinery section (TownSettingsPageClient.tsx) - Add toggle to rig settings Refinery section (RigSettingsPageClient.tsx) with inherit-from-town pattern Co-authored-by: John Fawcett * feat(gastown): extend GitHubPRStatusSchema with mergeable_state and wire conflict detection (#2474) * feat(gastown): extend GitHubPRStatusSchema with mergeable_state and wire conflict detection - Add mergeable and mergeable_state fields to GitHubPRStatusSchema - Update checkPRStatus return type to PRStatusResult { status, mergeable_state } - Update poll_pr action to detect dirty mergeable_state and emit pr_conflict_detected exactly once per conflict episode (idempotent via has_conflicts bead metadata flag) - Clear has_conflicts flag when mergeable_state transitions to clean/unknown - Add applyEvent('pr_conflict_detected') handler in reconciler.ts that creates gt:pr-conflict issue beads (or escalation beads when auto_resolve_merge_conflicts=false) - Handle gt:pr-conflict beads in review-queue.ts agentDone path (close directly, skip review) - Add pr_conflict_detected to TownEventType enum * fix(gastown): always emit pr_conflict_detected and reset auto-merge timer on dirty PRs - Remove the wantsAutoResolveConflicts guard so pr_conflict_detected is emitted regardless of config; the reconciler already branches on that setting to create either a conflict bead (auto-resolve on) or an escalation bead (auto-resolve off). - Return early from the dirty branch after resetting auto_merge_ready_since to NULL, preventing a dirty PR from keeping or completing its auto-merge grace period. --------- Co-authored-by: John Fawcett * feat(gastown): add pr_conflict_context to PrimeContext and consolidate conflict+feedback into single agent dispatch (#2477) * feat(gastown): add pr_conflict_context to PrimeContext and consolidate conflict+feedback beads - Add pr_conflict_context field to PrimeContext type with pr_url, branch, target_branch, and has_feedback fields - Populate pr_conflict_context in prime() for gt:pr-conflict beads, and also for gt:pr-feedback beads that have accumulated has_conflicts metadata - Add PR Conflict Resolution Workflow section to polecat system prompt so agents know to fetch, rebase, resolve, force-push, and call gt_done - In reconciler pr_conflict_detected: before creating a new gt:pr-conflict bead, check if an open gt:pr-feedback bead already exists for the same MR — if so, merge has_conflicts into its metadata instead of creating a separate bead - In reconciler pr_feedback_detected: before creating a new gt:pr-feedback bead, check if an open gt:pr-conflict bead already exists for the same MR — if so, merge has_feedback into its metadata instead - Refactor hasExistingPrConflictBead / hasExistingPrFeedbackBead to delegate to new getExisting* helpers that return the bead_id * fix: handle SQLite integer 1 for has_feedback and extend conflict workflow to pr-feedback beads - agents.ts: check has_feedback === 1 (SQLite integer) in addition to === true so consolidated conflict+feedback beads correctly surface the has_feedback flag - polecat-system.prompt.ts: conflict resolution workflow now triggers for gt:pr-feedback beads that have pr_conflict_context, not only gt:pr-conflict beads --------- Co-authored-by: John Fawcett * fix(reconciler): use resolveRigConfig for auto_resolve_merge_conflicts, guard unknown mergeable_state, add branch to gt_done prompt - In pr_conflict_detected handler, use resolveRigConfig(townConfig, rig.config) to get the effective config so town-level auto_resolve_merge_conflicts is respected even when the rig has no override. Pass townConfig from Town.do.ts into applyEvent and fetch it once before Phase 0 to share with Phase 1. - In poll_pr handler, guard against mergeable_state === 'unknown' by returning early (GitHub is still computing). Only clear has_conflicts when state is definitively clean ('clean', 'blocked', or 'has_hooks'). Only emit pr_conflict_detected when state is definitively 'dirty'. - In buildConflictResolutionPrompt, include the branch argument in the gt_done instruction so polecats don't fail the required-field validation. * fix(prompts): include branch arg in gt_done instruction for PR conflict workflow --------- Co-authored-by: John Fawcett --- .../settings/RigSettingsPageClient.tsx | 45 ++++ .../settings/TownSettingsPageClient.tsx | 17 ++ .../src/db/tables/town-events.table.ts | 1 + services/gastown/src/dos/Town.do.ts | 8 +- services/gastown/src/dos/town/actions.ts | 134 +++++++++- services/gastown/src/dos/town/agents.ts | 28 ++ services/gastown/src/dos/town/config.ts | 9 + services/gastown/src/dos/town/reconciler.ts | 241 ++++++++++++++++-- services/gastown/src/dos/town/review-queue.ts | 10 +- services/gastown/src/dos/town/town-scm.ts | 22 +- .../src/prompts/polecat-system.prompt.ts | 27 +- services/gastown/src/types.ts | 13 + services/gastown/src/util/platform-pr.util.ts | 2 + 13 files changed, 520 insertions(+), 37 deletions(-) diff --git a/apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/settings/RigSettingsPageClient.tsx b/apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/settings/RigSettingsPageClient.tsx index f7e05bd14d..61621384ad 100644 --- a/apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/settings/RigSettingsPageClient.tsx +++ b/apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/settings/RigSettingsPageClient.tsx @@ -112,6 +112,9 @@ export function RigSettingsPageClient({ townId, rigId, organizationId }: Props) const [autoResolvePrFeedback, setAutoResolvePrFeedback] = useState( undefined ); + const [autoResolveMergeConflicts, setAutoResolveMergeConflicts] = useState( + undefined + ); const [autoMergeDelayMinutes, setAutoMergeDelayMinutes] = useState( undefined ); @@ -136,6 +139,7 @@ export function RigSettingsPageClient({ townId, rigId, organizationId }: Props) setRefineryCodeReview(cfg.code_review); setReviewMode(cfg.review_mode); setAutoResolvePrFeedback(cfg.auto_resolve_pr_feedback); + setAutoResolveMergeConflicts(cfg.auto_resolve_merge_conflicts); setAutoMergeDelayMinutes(cfg.auto_merge_delay_minutes); setMergeStrategy(cfg.merge_strategy); setConvoyMergeMode(cfg.convoy_merge_mode); @@ -183,6 +187,7 @@ export function RigSettingsPageClient({ townId, rigId, organizationId }: Props) code_review: refineryCodeReview, review_mode: reviewMode, auto_resolve_pr_feedback: autoResolvePrFeedback, + auto_resolve_merge_conflicts: autoResolveMergeConflicts, auto_merge_delay_minutes: autoMergeDelayMinutes, merge_strategy: mergeStrategy, convoy_merge_mode: convoyMergeMode, @@ -505,6 +510,46 @@ export function RigSettingsPageClient({ townId, rigId, organizationId }: Props)
+
+
+
+ +

+ When a PR has merge conflicts, automatically dispatch an agent to rebase + and resolve them. + {townCfg?.refinery?.auto_resolve_merge_conflicts !== undefined && ( + + (Town default:{' '} + {townCfg.refinery.auto_resolve_merge_conflicts ? 'on' : 'off'}) + + )} +

+
+
+ {autoResolveMergeConflicts !== undefined && ( + + )} + setAutoResolveMergeConflicts(v)} + className={autoResolveMergeConflicts === undefined ? 'opacity-40' : ''} + /> +
+
+
+
+
+
+ +

+ When a PR has merge conflicts, automatically dispatch an agent to rebase and + resolve them. +

+
+ +
+ {autoResolvePrFeedback && (
diff --git a/services/gastown/src/db/tables/town-events.table.ts b/services/gastown/src/db/tables/town-events.table.ts index 436aacecae..72771b8613 100644 --- a/services/gastown/src/db/tables/town-events.table.ts +++ b/services/gastown/src/db/tables/town-events.table.ts @@ -13,6 +13,7 @@ export const TownEventType = z.enum([ 'nudge_timeout', 'pr_feedback_detected', 'pr_auto_merge', + 'pr_conflict_detected', ]); export type TownEventType = z.output; diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index 695c87ea77..b7d0e475d4 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -3673,6 +3673,11 @@ export class TownDO extends DurableObject { pendingEventCount: 0, }; + // Fetch town config once and share across Phase 0 and Phase 1 so that + // applyEvent can use the full fallback chain (rig → town → default) for + // settings like auto_resolve_merge_conflicts. + const townConfig = await this.getTownConfig(); + // Phase 0: Drain events and apply state transitions try { const pending = events.drainEvents(this.sql); @@ -3682,7 +3687,7 @@ export class TownDO extends DurableObject { } for (const event of pending) { try { - reconciler.applyEvent(this.sql, event); + reconciler.applyEvent(this.sql, event, { townConfig }); events.markProcessed(this.sql, event.event_id); } catch (err) { logger.error('reconciler: applyEvent failed', { @@ -3723,7 +3728,6 @@ export class TownDO extends DurableObject { // Phase 1: Reconcile — compute desired state vs actual state const sideEffects: Array<() => Promise> = []; try { - const townConfig = await this.getTownConfig(); const actions = reconciler.reconcile(this.sql, { draining: this._draining, townConfig, diff --git a/services/gastown/src/dos/town/actions.ts b/services/gastown/src/dos/town/actions.ts index bb1482d035..f5b43b861a 100644 --- a/services/gastown/src/dos/town/actions.ts +++ b/services/gastown/src/dos/town/actions.ts @@ -22,6 +22,7 @@ import * as reviewQueue from './review-queue'; import * as patrol from './patrol'; import { getRig } from './rigs'; import { parseGitUrl } from '../../util/platform-pr.util'; +import type { PRStatusResult } from './town-scm'; // ── Bead mutations ────────────────────────────────────────────────── @@ -279,8 +280,8 @@ export type ApplyActionContext = { dispatchAgent: (agentId: string, beadId: string, rigId: string) => Promise; /** Stop an agent's container process. */ stopAgent: (agentId: string) => Promise; - /** Check a PR's status via GitHub/GitLab API. Returns 'open'|'merged'|'closed'|null. */ - checkPRStatus: (prUrl: string) => Promise<'open' | 'merged' | 'closed' | null>; + /** Check a PR's status via GitHub/GitLab API. Returns PRStatusResult or null. */ + checkPRStatus: (prUrl: string) => Promise; /** Check PR for unresolved review comments and failing CI checks. */ checkPRFeedback: (prUrl: string) => Promise; /** Merge a PR via GitHub/GitLab API. */ @@ -724,8 +725,8 @@ export function applyAction(ctx: ApplyActionContext, action: Action): (() => Pro return async () => { try { - const status = await ctx.checkPRStatus(action.pr_url); - if (status !== null) { + const prStatusResult = await ctx.checkPRStatus(action.pr_url); + if (prStatusResult !== null) { // Any non-null result resets the consecutive null counter query( sql, @@ -739,6 +740,7 @@ export function applyAction(ctx: ApplyActionContext, action: Action): (() => Pro `, [action.bead_id] ); + const { status, mergeable_state } = prStatusResult; if (status !== 'open') { ctx.insertEvent('pr_status_changed', { bead_id: action.bead_id, @@ -752,6 +754,124 @@ export function applyAction(ctx: ApplyActionContext, action: Action): (() => Pro const refineryConfig = townConfig.refinery; if (!refineryConfig) return; + if (mergeable_state === 'unknown') { + // GitHub is still computing mergeability — skip this poll and + // check again on the next tick. Do NOT treat 'unknown' as clean + // or dirty to avoid prematurely clearing has_conflicts or + // emitting pr_conflict_detected before GitHub has a definitive answer. + return; + } + + if (mergeable_state === 'dirty') { + // PR has merge conflicts — emit event ONCE per conflict episode. + // The reconciler decides whether to create a conflict bead or an escalation + // based on the rig's auto_resolve_merge_conflicts config. + const conflictMetaRows = z + .object({ has_conflicts: z.unknown() }) + .array() + .parse([ + ...query( + sql, + /* sql */ ` + SELECT json_extract(${beads.columns.metadata}, '$.has_conflicts') AS has_conflicts + FROM ${beads} + WHERE ${beads.bead_id} = ? + `, + [action.bead_id] + ), + ]); + const alreadyMarked = conflictMetaRows[0]?.has_conflicts === 1 || + conflictMetaRows[0]?.has_conflicts === true; + + if (!alreadyMarked) { + // Mark conflict on MR bead metadata + query( + sql, + /* sql */ ` + UPDATE ${beads} + SET ${beads.columns.metadata} = json_set( + COALESCE(${beads.columns.metadata}, '{}'), + '$.has_conflicts', 1, + '$.conflicts_detected_at', ? + ), + ${beads.columns.updated_at} = ? + WHERE ${beads.bead_id} = ? + `, + [now(), now(), action.bead_id] + ); + + // Get MR bead source bead ID and branch for the event payload + const mrMetaRows = z + .object({ source_bead_id: z.string().nullable(), branch: z.string().nullable() }) + .array() + .parse([ + ...query( + sql, + /* sql */ ` + SELECT + json_extract(${beads.columns.metadata}, '$.source_bead_id') AS source_bead_id, + ${review_metadata.columns.branch} AS branch + FROM ${beads} + INNER JOIN ${review_metadata} ON ${review_metadata.bead_id} = ${beads.bead_id} + WHERE ${beads.bead_id} = ? + `, + [action.bead_id] + ), + ]); + const sourceBead = mrMetaRows[0]?.source_bead_id ?? null; + const conflictBranch = mrMetaRows[0]?.branch ?? ''; + + ctx.insertEvent('pr_conflict_detected', { + bead_id: action.bead_id, + payload: { + mr_bead_id: action.bead_id, + source_bead_id: sourceBead, + pr_url: action.pr_url, + branch: conflictBranch, + }, + }); + } + + // A dirty PR must not proceed to the auto-merge timer — reset the + // grace-period clock so the timer starts fresh once conflicts are resolved. + query( + sql, + /* sql */ ` + UPDATE ${review_metadata} + SET ${review_metadata.columns.auto_merge_ready_since} = NULL + WHERE ${review_metadata.bead_id} = ? + AND ${review_metadata.columns.auto_merge_ready_since} IS NOT NULL + `, + [action.bead_id] + ); + return; + } else if ( + mergeable_state === 'clean' || + mergeable_state === 'blocked' || + mergeable_state === 'has_hooks' + ) { + // Conflict definitively resolved — clear the has_conflicts flag. + // 'clean': no conflicts, all checks pass. + // 'blocked': no conflicts but checks are failing (e.g. required reviews). + // 'has_hooks': no conflicts but pre-receive hooks are pending. + // 'unknown' is handled above (GitHub still computing — retry next poll). + query( + sql, + /* sql */ ` + UPDATE ${beads} + SET ${beads.columns.metadata} = json_remove( + COALESCE(${beads.columns.metadata}, '{}'), + '$.has_conflicts', + '$.conflicts_detected_at' + ), + ${beads.columns.updated_at} = ? + WHERE ${beads.bead_id} = ? + AND json_extract(${beads.columns.metadata}, '$.has_conflicts') IS NOT NULL + `, + [now(), action.bead_id] + ); + } + const wantsAutoResolve = refineryConfig.auto_resolve_pr_feedback === true; const wantsAutoMerge = refineryConfig.auto_merge !== false && @@ -777,10 +897,10 @@ export function applyAction(ctx: ApplyActionContext, action: Action): (() => Pro // If the PR was merged externally during that window, inserting // pr_feedback_detected would create a feedback bead for a merged // PR — leading to a duplicate PR on an already-merged branch. - const freshStatus = await ctx.checkPRStatus(action.pr_url); - if (freshStatus !== 'open') { + const freshStatusResult = await ctx.checkPRStatus(action.pr_url); + if (freshStatusResult?.status !== 'open') { console.log( - `${LOG} poll_pr: PR status changed to '${freshStatus}' during feedback check, skipping feedback for bead=${action.bead_id}` + `${LOG} poll_pr: PR status changed to '${freshStatusResult?.status ?? 'null'}' during feedback check, skipping feedback for bead=${action.bead_id}` ); } else { const existingFeedback = hasExistingFeedbackBead(sql, action.bead_id); diff --git a/services/gastown/src/dos/town/agents.ts b/services/gastown/src/dos/town/agents.ts index 8b6c2765dc..cbcefc468f 100644 --- a/services/gastown/src/dos/town/agents.ts +++ b/services/gastown/src/dos/town/agents.ts @@ -519,6 +519,33 @@ export function prime(sql: SqlStorage, agentId: string): PrimeContext { }; } + // Build PR conflict context if the hooked bead is a PR conflict resolution request, + // or if it is a PR feedback bead that has also accumulated merge conflicts. + let pr_conflict_context: PrimeContext['pr_conflict_context'] = null; + if (hookedBead?.labels.includes('gt:pr-conflict') && hookedBead.metadata) { + const meta = hookedBead.metadata as Record; + pr_conflict_context = { + pr_url: typeof meta.pr_url === 'string' ? meta.pr_url : null, + branch: typeof meta.branch === 'string' ? meta.branch : null, + target_branch: typeof meta.target_branch === 'string' ? meta.target_branch : null, + has_feedback: meta.has_feedback === true || meta.has_feedback === 1, + }; + } else if (hookedBead?.labels.includes('gt:pr-feedback') && hookedBead.metadata) { + // A feedback bead can also have has_conflicts: true when a conflict was detected + // after the feedback bead was already created. Surface the conflict context so the + // agent resolves conflicts first, then addresses review feedback. + const meta = hookedBead.metadata as Record; + if (meta.has_conflicts === true || meta.has_conflicts === 1) { + pr_conflict_context = { + pr_url: typeof meta.pr_url === 'string' ? meta.pr_url : null, + branch: typeof meta.branch === 'string' ? meta.branch : null, + target_branch: + typeof meta.conflict_target_branch === 'string' ? meta.conflict_target_branch : null, + has_feedback: true, + }; + } + } + return { agent, hooked_bead: hookedBead, @@ -526,6 +553,7 @@ export function prime(sql: SqlStorage, agentId: string): PrimeContext { open_beads: openBeads, rework_context, pr_fixup_context, + pr_conflict_context, }; } diff --git a/services/gastown/src/dos/town/config.ts b/services/gastown/src/dos/town/config.ts index 156211115a..403d77fd75 100644 --- a/services/gastown/src/dos/town/config.ts +++ b/services/gastown/src/dos/town/config.ts @@ -89,6 +89,10 @@ export async function updateTownConfig( update.refinery.auto_resolve_pr_feedback ?? current.refinery?.auto_resolve_pr_feedback ?? false, + auto_resolve_merge_conflicts: + update.refinery.auto_resolve_merge_conflicts ?? + current.refinery?.auto_resolve_merge_conflicts ?? + true, auto_merge_delay_minutes: update.refinery.auto_merge_delay_minutes !== undefined ? update.refinery.auto_merge_delay_minutes @@ -191,6 +195,7 @@ export type EffectiveConfig = { review_mode: 'rework' | 'comments'; code_review: boolean; auto_resolve_pr_feedback: boolean; + auto_resolve_merge_conflicts: boolean; auto_merge_delay_minutes: number | null; merge_strategy: MergeStrategy; convoy_merge_mode: 'review-then-land' | 'review-and-merge'; @@ -227,6 +232,10 @@ export function resolveRigConfig( rigOverride?.auto_resolve_pr_feedback ?? townConfig.refinery?.auto_resolve_pr_feedback ?? false, + auto_resolve_merge_conflicts: + rigOverride?.auto_resolve_merge_conflicts ?? + townConfig.refinery?.auto_resolve_merge_conflicts ?? + true, auto_merge_delay_minutes: rigOverride?.auto_merge_delay_minutes !== undefined ? rigOverride.auto_merge_delay_minutes diff --git a/services/gastown/src/dos/town/reconciler.ts b/services/gastown/src/dos/town/reconciler.ts index 75ee36a483..f4753a4b60 100644 --- a/services/gastown/src/dos/town/reconciler.ts +++ b/services/gastown/src/dos/town/reconciler.ts @@ -211,7 +211,11 @@ type ConvoyRow = z.infer; * * See reconciliation-spec.md §5.2. */ -export function applyEvent(sql: SqlStorage, event: TownEventRecord): void { +export function applyEvent( + sql: SqlStorage, + event: TownEventRecord, + opts?: { townConfig?: TownConfig } +): void { const payload = event.payload; switch (event.event_type) { @@ -403,6 +407,27 @@ export function applyEvent(sql: SqlStorage, event: TownEventRecord): void { const hasFailingChecks = payload.has_failing_checks === true; const hasUncheckedRuns = payload.has_unchecked_runs === true; + // Consolidation: if there's already an open gt:pr-conflict bead for this MR, + // add has_feedback: true to it instead of creating a separate feedback bead. + // The agent resolving conflicts will then also address review feedback afterward. + const existingConflictBeadId = getExistingPrConflictBeadId(sql, mrBeadId); + if (existingConflictBeadId) { + query( + sql, + /* sql */ ` + UPDATE ${beads} + SET ${beads.columns.metadata} = json_set(COALESCE(${beads.metadata}, '{}'), '$.has_feedback', 1), + ${beads.columns.updated_at} = ? + WHERE ${beads.bead_id} = ? + `, + [new Date().toISOString(), existingConflictBeadId] + ); + console.log( + `${LOG} pr_feedback_detected: merged into existing conflict bead ${existingConflictBeadId} (mrBeadId=${mrBeadId})` + ); + return; + } + const feedbackBead = beadOps.createBead(sql, { type: 'issue', title: buildFeedbackBeadTitle( @@ -435,6 +460,109 @@ export function applyEvent(sql: SqlStorage, event: TownEventRecord): void { return; } + case 'pr_conflict_detected': { + const mrBeadId = typeof payload.mr_bead_id === 'string' ? payload.mr_bead_id : null; + if (!mrBeadId) { + console.warn(`${LOG} applyEvent: pr_conflict_detected missing mr_bead_id`); + return; + } + + const mrBead = beadOps.getBead(sql, mrBeadId); + if (!mrBead || mrBead.status === 'closed' || mrBead.status === 'failed') return; + + // Idempotent: check for an existing open gt:pr-conflict bead for this pr_url + if (hasExistingPrConflictBead(sql, mrBeadId)) return; + + const prUrl = typeof payload.pr_url === 'string' ? payload.pr_url : ''; + const branch = typeof payload.branch === 'string' ? payload.branch : ''; + const sourceBead = typeof payload.source_bead_id === 'string' ? payload.source_bead_id : null; + + // Read the target_branch from review_metadata + const rmRows = z + .object({ target_branch: z.string() }) + .array() + .parse([ + ...query( + sql, + /* sql */ ` + SELECT ${review_metadata.columns.target_branch} + FROM ${review_metadata} + WHERE ${review_metadata.bead_id} = ? + `, + [mrBeadId] + ), + ]); + const targetBranch = rmRows[0]?.target_branch ?? ''; + + // Read auto_resolve_merge_conflicts using the same fallback chain as + // auto_resolve_pr_feedback: rig override → town config → default (true). + const rig = mrBead.rig_id ? getRig(sql, mrBead.rig_id) : null; + const effectiveConfig = opts?.townConfig + ? resolveRigConfig(opts.townConfig, rig?.config ?? null) + : { auto_resolve_merge_conflicts: rig?.config?.auto_resolve_merge_conflicts !== false }; + const autoResolveConflicts = effectiveConfig.auto_resolve_merge_conflicts !== false; + + if (autoResolveConflicts) { + // Consolidation: if there's already an open gt:pr-feedback bead for this MR, + // add has_conflicts: true to it instead of creating a separate conflict bead. + // The agent handling the feedback bead will resolve conflicts first, then + // address review comments. + const existingFeedbackBeadId = getExistingPrFeedbackBeadId(sql, mrBeadId); + if (existingFeedbackBeadId) { + query( + sql, + /* sql */ ` + UPDATE ${beads} + SET ${beads.columns.metadata} = json_set(COALESCE(${beads.metadata}, '{}'), '$.has_conflicts', 1, '$.conflict_target_branch', ?), + ${beads.columns.updated_at} = ? + WHERE ${beads.bead_id} = ? + `, + [targetBranch, new Date().toISOString(), existingFeedbackBeadId] + ); + console.log( + `${LOG} pr_conflict_detected: merged into existing feedback bead ${existingFeedbackBeadId} (mrBeadId=${mrBeadId})` + ); + return; + } + + const conflictBead = beadOps.createBead(sql, { + type: 'issue', + title: `Resolve merge conflicts on PR: ${branch}`, + body: buildConflictResolutionPrompt(prUrl, branch, targetBranch), + rig_id: mrBead.rig_id ?? undefined, + parent_bead_id: mrBeadId, + labels: ['gt:pr-conflict'], + metadata: { + pr_url: prUrl, + branch, + target_branch: targetBranch, + mr_bead_id: mrBeadId, + source_bead_id: sourceBead, + }, + }); + + // Conflict bead blocks the MR bead (same pattern as feedback beads) + beadOps.insertDependency(sql, mrBeadId, conflictBead.bead_id, 'blocks'); + } else { + // auto_resolve_merge_conflicts disabled — create an escalation bead + beadOps.createBead(sql, { + type: 'escalation', + title: `Merge conflict detected: ${branch}`, + body: `PR ${prUrl} (branch ${branch}) has merge conflicts that require manual resolution.`, + priority: 'high', + metadata: { + pr_url: prUrl, + branch, + target_branch: targetBranch, + mr_bead_id: mrBeadId, + source_bead_id: sourceBead, + conflict: true, + }, + }); + } + return; + } + case 'pr_auto_merge': { const mrBeadId = typeof payload.mr_bead_id === 'string' ? payload.mr_bead_id : null; if (!mrBeadId) { @@ -2173,24 +2301,62 @@ function hasRecentNudge(sql: SqlStorage, agentId: string, tier: string): boolean return rows.length > 0; } +/** Check if an MR bead has a non-terminal conflict bead (gt:pr-conflict) blocking it. */ +function hasExistingPrConflictBead(sql: SqlStorage, mrBeadId: string): boolean { + return getExistingPrConflictBeadId(sql, mrBeadId) !== null; +} + +/** Return the bead_id of a non-terminal conflict bead (gt:pr-conflict) blocking the MR, or null. */ +function getExistingPrConflictBeadId(sql: SqlStorage, mrBeadId: string): string | null { + const rows = z + .object({ bead_id: z.string() }) + .array() + .parse([ + ...query( + sql, + /* sql */ ` + SELECT fb.${beads.columns.bead_id} + FROM ${bead_dependencies} bd + INNER JOIN ${beads} fb ON fb.${beads.columns.bead_id} = bd.${bead_dependencies.columns.depends_on_bead_id} + WHERE bd.${bead_dependencies.columns.bead_id} = ? + AND bd.${bead_dependencies.columns.dependency_type} = 'blocks' + AND fb.${beads.columns.labels} LIKE '%gt:pr-conflict%' + AND fb.${beads.columns.status} NOT IN ('closed', 'failed') + LIMIT 1 + `, + [mrBeadId] + ), + ]); + return rows.length > 0 ? rows[0].bead_id : null; +} + /** Check if an MR bead has a non-terminal feedback bead (gt:pr-feedback) blocking it. */ function hasExistingPrFeedbackBead(sql: SqlStorage, mrBeadId: string): boolean { - const rows = [ - ...query( - sql, - /* sql */ ` - SELECT 1 FROM ${bead_dependencies} bd - INNER JOIN ${beads} fb ON fb.${beads.columns.bead_id} = bd.${bead_dependencies.columns.depends_on_bead_id} - WHERE bd.${bead_dependencies.columns.bead_id} = ? - AND bd.${bead_dependencies.columns.dependency_type} = 'blocks' - AND fb.${beads.columns.labels} LIKE '%gt:pr-feedback%' - AND fb.${beads.columns.status} NOT IN ('closed', 'failed') - LIMIT 1 - `, - [mrBeadId] - ), - ]; - return rows.length > 0; + return getExistingPrFeedbackBeadId(sql, mrBeadId) !== null; +} + +/** Return the bead_id of a non-terminal feedback bead (gt:pr-feedback) blocking the MR, or null. */ +function getExistingPrFeedbackBeadId(sql: SqlStorage, mrBeadId: string): string | null { + const rows = z + .object({ bead_id: z.string() }) + .array() + .parse([ + ...query( + sql, + /* sql */ ` + SELECT fb.${beads.columns.bead_id} + FROM ${bead_dependencies} bd + INNER JOIN ${beads} fb ON fb.${beads.columns.bead_id} = bd.${bead_dependencies.columns.depends_on_bead_id} + WHERE bd.${bead_dependencies.columns.bead_id} = ? + AND bd.${bead_dependencies.columns.dependency_type} = 'blocks' + AND fb.${beads.columns.labels} LIKE '%gt:pr-feedback%' + AND fb.${beads.columns.status} NOT IN ('closed', 'failed') + LIMIT 1 + `, + [mrBeadId] + ), + ]); + return rows.length > 0 ? rows[0].bead_id : null; } /** Build a human-readable title for the feedback bead. */ @@ -2271,6 +2437,47 @@ function buildFeedbackPrompt( return lines.join('\n'); } +/** Build the polecat prompt body for resolving merge conflicts on a PR branch. */ +function buildConflictResolutionPrompt( + prUrl: string, + branch: string, + targetBranch: string +): string { + const lines: string[] = []; + lines.push(`You are resolving merge conflicts on branch \`${branch}\`.`); + lines.push(`The PR is: ${prUrl}`); + lines.push(`The target branch is: \`${targetBranch}\``); + lines.push(''); + lines.push('## Steps'); + lines.push(''); + lines.push('1. Fetch the latest state of the remote:'); + lines.push(' ```'); + lines.push(' git fetch origin'); + lines.push(' ```'); + lines.push(''); + lines.push(`2. Rebase your branch onto the target branch to incorporate its latest changes:`); + lines.push(' ```'); + lines.push(` git rebase origin/${targetBranch}`); + lines.push(' ```'); + lines.push(''); + lines.push('3. If there are conflicts during rebase, resolve them:'); + lines.push(' - Edit the conflicting files to resolve the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`)'); + lines.push(' - Stage the resolved files: `git add `'); + lines.push(' - Continue the rebase: `git rebase --continue`'); + lines.push(' - Repeat until the rebase completes'); + lines.push(''); + lines.push('4. Push the rebased branch:'); + lines.push(' ```'); + lines.push(` git push --force-with-lease origin ${branch}`); + lines.push(' ```'); + lines.push(''); + lines.push('5. Call `gt_done` once the push succeeds, passing both required arguments:'); + lines.push(` - \`pr_url\`: \`${prUrl}\``); + lines.push(` - \`branch\`: \`${branch}\``); + + return lines.join('\n'); +} + // ════════════════════════════════════════════════════════════════════ // Invariant checker — runs after action application to detect // violations of the system invariants from spec §6. diff --git a/services/gastown/src/dos/town/review-queue.ts b/services/gastown/src/dos/town/review-queue.ts index da6402496c..7e618aaa0d 100644 --- a/services/gastown/src/dos/town/review-queue.ts +++ b/services/gastown/src/dos/town/review-queue.ts @@ -532,9 +532,15 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu // PR-fixup beads skip the review queue. The polecat pushed fixup commits // to an existing PR branch — no separate review is needed. - if (hookedBead?.labels.includes('gt:pr-fixup')) { + // PR-conflict beads also skip the review queue: the polecat rebased and + // force-pushed the branch to resolve conflicts — closing the bead unblocks + // the parent MR bead so poll_pr can re-check mergeable_state. + if ( + hookedBead?.labels.includes('gt:pr-fixup') || + hookedBead?.labels.includes('gt:pr-conflict') + ) { console.log( - `[review-queue] agentDone: pr-fixup bead ${agent.current_hook_bead_id} — closing directly (skip review)` + `[review-queue] agentDone: ${hookedBead.labels.includes('gt:pr-conflict') ? 'pr-conflict' : 'pr-fixup'} bead ${agent.current_hook_bead_id} — closing directly (skip review)` ); closeBead(sql, agent.current_hook_bead_id, agentId); unhookBead(sql, agentId); diff --git a/services/gastown/src/dos/town/town-scm.ts b/services/gastown/src/dos/town/town-scm.ts index 398e111622..8e2bddf377 100644 --- a/services/gastown/src/dos/town/town-scm.ts +++ b/services/gastown/src/dos/town/town-scm.ts @@ -45,14 +45,20 @@ export async function resolveGitHubToken(ctx: SCMContext): Promise { +): Promise { const townConfig = await ctx.getTownConfig(); // GitHub PR URL format: https://github.com/{owner}/{repo}/pull/{number} @@ -87,9 +93,9 @@ export async function checkPRStatus( const data = GitHubPRStatusSchema.safeParse(json); if (!data.success) return null; - if (data.data.merged) return 'merged'; - if (data.data.state === 'closed') return 'closed'; - return 'open'; + if (data.data.merged) return { status: 'merged' }; + if (data.data.state === 'closed') return { status: 'closed' }; + return { status: 'open', mergeable_state: data.data.mergeable_state }; } // GitLab MR URL format: https://{host}/{path}/-/merge_requests/{iid} @@ -133,9 +139,9 @@ export async function checkPRStatus( const data = GitLabMRStatusSchema.safeParse(glJson); if (!data.success) return null; - if (data.data.state === 'merged') return 'merged'; - if (data.data.state === 'closed') return 'closed'; - return 'open'; + if (data.data.state === 'merged') return { status: 'merged' }; + if (data.data.state === 'closed') return { status: 'closed' }; + return { status: 'open' }; } console.warn(`${TOWN_LOG} checkPRStatus: unrecognized PR URL format: ${prUrl}`); diff --git a/services/gastown/src/prompts/polecat-system.prompt.ts b/services/gastown/src/prompts/polecat-system.prompt.ts index 8815dea0e0..e00e4636b3 100644 --- a/services/gastown/src/prompts/polecat-system.prompt.ts +++ b/services/gastown/src/prompts/polecat-system.prompt.ts @@ -82,6 +82,31 @@ After all gates pass and your work is complete, create a pull request before cal ` : '' } +## PR Conflict Resolution Workflow + +When your hooked bead has the \`gt:pr-conflict\` label, **or** when it has the \`gt:pr-feedback\` label and \`pr_conflict_context\` is present in your context, you are resolving merge conflicts on an existing PR branch. **This is an exception to the "do not switch branches" rule.** You MUST check out the PR branch from your bead metadata (\`pr_conflict_context.branch\`). + +1. Check out the PR branch: \`git fetch origin && git checkout \` +2. Rebase onto the target branch to incorporate its latest changes: + ``` + git rebase origin/ + ``` +3. If there are conflicts during rebase, resolve them: + - Edit conflicting files to resolve conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`) + - Stage the resolved files: \`git add \` + - Continue the rebase: \`git rebase --continue\` + - Repeat until the rebase completes +4. Push the rebased branch: + ``` + git push --force-with-lease origin + ``` +5. If the bead metadata has `has_feedback: true`, also address the PR review feedback (see PR Fixup Workflow below) before calling gt_done. + 6. Call \`gt_done\` with both required arguments once all conflicts are resolved (and feedback addressed if applicable): + - \`pr_url\`: the PR URL from \`pr_conflict_context.pr_url\` + - \`branch\`: the branch name from \`pr_conflict_context.branch\` + +Do NOT create a new PR. Push to the existing branch. + ## PR Fixup Workflow When your hooked bead has the \`gt:pr-fixup\` label, you are fixing an existing PR rather than creating new work. **This is the ONE exception to the "do not switch branches" rule.** You MUST check out the PR branch from your bead metadata instead of using the default worktree branch. @@ -101,7 +126,7 @@ Do NOT create a new PR. Push to the existing branch. - Commit after every meaningful unit of work (new function, passing test, config change). - Push after every commit. Do not batch pushes. - Use descriptive commit messages referencing the bead if applicable. -- Branch naming: your branch is pre-configured in your worktree. Do not switch branches — **unless** your bead has the \`gt:pr-fixup\` label (see PR Fixup Workflow above). +- Branch naming: your branch is pre-configured in your worktree. Do not switch branches — **unless** your bead has the \`gt:pr-fixup\` or \`gt:pr-conflict\` label (see workflows above). ## Escalation diff --git a/services/gastown/src/types.ts b/services/gastown/src/types.ts index 7579a4810d..73b7bf604b 100644 --- a/services/gastown/src/types.ts +++ b/services/gastown/src/types.ts @@ -177,6 +177,14 @@ export type PrimeContext = { branch: string | null; target_branch: string | null; } | null; + /** Present when the hooked bead is a PR conflict resolution (gt:pr-conflict label). */ + pr_conflict_context: { + pr_url: string | null; + branch: string | null; + target_branch: string | null; + /** When true, the bead also has pending review feedback to address after resolving conflicts. */ + has_feedback: boolean; + } | null; }; // -- Agent done -- @@ -275,6 +283,9 @@ export const TownConfigSchema = z.object({ /** When enabled, a polecat is automatically dispatched to address * unresolved review comments and failing CI checks on open PRs. */ auto_resolve_pr_feedback: z.boolean().default(false), + /** When enabled, a polecat is automatically dispatched to rebase and + * resolve merge conflicts on open PRs. */ + auto_resolve_merge_conflicts: z.boolean().default(true).optional(), /** After all CI checks pass and all review threads are resolved, * automatically merge the PR after this many minutes. * 0 = immediate, null = disabled (require manual merge). */ @@ -347,6 +358,7 @@ export const RigOverrideConfigSchema = z.object({ /** false = skip refinery entirely */ code_review: z.boolean().optional(), auto_resolve_pr_feedback: z.boolean().optional(), + auto_resolve_merge_conflicts: z.boolean().optional(), auto_merge_delay_minutes: z.number().int().min(0).nullable().optional(), // Merge strategy @@ -412,6 +424,7 @@ export const TownConfigUpdateSchema = z.object({ code_review: z.boolean().optional(), review_mode: z.enum(['rework', 'comments']).optional(), auto_resolve_pr_feedback: z.boolean().optional(), + auto_resolve_merge_conflicts: z.boolean().optional(), auto_merge_delay_minutes: z.number().int().min(0).nullable().optional(), }) .optional(), diff --git a/services/gastown/src/util/platform-pr.util.ts b/services/gastown/src/util/platform-pr.util.ts index c322bae5fa..c05bff3431 100644 --- a/services/gastown/src/util/platform-pr.util.ts +++ b/services/gastown/src/util/platform-pr.util.ts @@ -174,6 +174,8 @@ ${diffSection} export const GitHubPRStatusSchema = z.object({ state: z.string(), merged: z.boolean().optional(), + mergeable: z.boolean().nullable().optional(), + mergeable_state: z.string().optional(), // 'clean', 'dirty', 'blocked', 'unknown', 'unstable' }); /** Schema for GitLab MR status responses (used by checkPRStatus). */ From 8e4e2c117790865a199d4d82ae31221900843114 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 18 Apr 2026 03:51:10 +0100 Subject: [PATCH 19/33] =?UTF-8?q?feat(gastown):=20add=20bulk=20bead=20dele?= =?UTF-8?q?tion=20=E2=80=94=20array=20support=20for=20gt=5Fbead=5Fdelete?= =?UTF-8?q?=20and=20bulk=20delete=20UI=20(#2572)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP: container eviction save * feat(gastown): add bulk delete and delete-by-status mayor API endpoints Add POST routes for bulk-delete and delete-by-status operations on beads, with corresponding handler functions that validate rig ownership and delegate to TownDO methods. * feat(gastown): add bulk bead deletion UI and bulk delete endpoints - Update deleteBead tRPC mutation to accept beadId: string | string[] - Add deleteBeadsByStatus tRPC mutation for bulk delete by status - Add adminBulkDeleteBeads and adminDeleteBeadsByStatus admin mutations - Add bulk delete methods to gastown container plugin client.ts - Update gt_bead_delete mayor tool to accept single ID or array - Add checkbox multi-select + bulk action bar to BeadsPageClient - Add 'Delete all failed (N)' button on Beads page - Add checkbox multi-select + bulk delete to admin BeadsTab - Add bulk delete and delete-by-status admin mutations to gastown-router.ts * fix(admin): pass typeFilter to deleteBeadsByStatus to respect active type filter --------- Co-authored-by: John Fawcett --- .../[townId]/beads/BeadsPageClient.tsx | 214 +++++++++++++++++- .../admin/gastown/towns/[townId]/BeadsTab.tsx | 181 +++++++++++++-- apps/web/src/routers/admin/gastown-router.ts | 34 +++ pnpm-lock.yaml | 22 +- services/gastown/container/plugin/client.ts | 24 ++ .../gastown/container/plugin/mayor-tools.ts | 18 +- services/gastown/src/dos/Town.do.ts | 28 +++ services/gastown/src/dos/town/beads.ts | 138 +++++++++++ services/gastown/src/gastown.worker.ts | 12 + .../src/handlers/mayor-tools.handler.ts | 63 ++++++ .../src/prompts/mayor-system.prompt.ts | 2 +- services/gastown/src/trpc/router.ts | 61 ++++- 12 files changed, 748 insertions(+), 49 deletions(-) diff --git a/apps/web/src/app/(app)/gastown/[townId]/beads/BeadsPageClient.tsx b/apps/web/src/app/(app)/gastown/[townId]/beads/BeadsPageClient.tsx index 6c85cd9744..c2b1f8304e 100644 --- a/apps/web/src/app/(app)/gastown/[townId]/beads/BeadsPageClient.tsx +++ b/apps/web/src/app/(app)/gastown/[townId]/beads/BeadsPageClient.tsx @@ -1,14 +1,23 @@ 'use client'; -import { useState, useMemo } from 'react'; -import { useQuery, useQueries } from '@tanstack/react-query'; +import { useState, useMemo, useCallback } from 'react'; +import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query'; import { useGastownTRPC } from '@/lib/gastown/trpc'; import { useDrawerStack } from '@/components/gastown/DrawerStack'; -import { Hexagon, Search } from 'lucide-react'; +import { Hexagon, Search, Trash2, X } from 'lucide-react'; import { SidebarTrigger } from '@/components/ui/sidebar'; import { formatDistanceToNow } from 'date-fns'; import { motion, AnimatePresence } from 'motion/react'; import type { GastownOutputs } from '@/lib/gastown/trpc'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; type Bead = GastownOutputs['gastown']['listBeads'][number]; @@ -23,11 +32,18 @@ const STATUS_DOT: Record = { failed: 'bg-red-400', }; +type DeleteConfirm = + | { kind: 'selected'; ids: string[]; rigId: string } + | { kind: 'all-failed'; count: number; rigIds: string[] }; + export function BeadsPageClient({ townId }: BeadsPageClientProps) { const trpc = useGastownTRPC(); + const queryClient = useQueryClient(); const { open: openDrawer } = useDrawerStack(); const [statusFilter, setStatusFilter] = useState(null); const [search, setSearch] = useState(''); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [deleteConfirm, setDeleteConfirm] = useState(null); const rigsQuery = useQuery(trpc.gastown.listRigs.queryOptions({ townId })); const rigs = rigsQuery.data ?? []; @@ -78,8 +94,92 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) { return counts; }, [allBeads]); + const failedBeads = useMemo(() => allBeads.filter(b => b.status === 'failed'), [allBeads]); + const isLoading = rigsQuery.isLoading || rigBeadQueries.some(q => q.isLoading); + const invalidateBeads = useCallback(() => { + for (const rig of rigs) { + void queryClient.invalidateQueries(trpc.gastown.listBeads.queryFilter({ rigId: rig.id })); + } + }, [queryClient, rigs, trpc.gastown.listBeads]); + + const deleteBeadMutation = useMutation( + trpc.gastown.deleteBead.mutationOptions({ + onSuccess: () => { + invalidateBeads(); + setSelectedIds(new Set()); + setDeleteConfirm(null); + }, + }) + ); + + const isDeleting = deleteBeadMutation.isPending; + + // Build a map from bead_id -> rigId for lookups + const beadRigMap = useMemo(() => { + const map = new Map(); + for (const bead of allBeads) { + map.set(bead.bead_id, bead.rigId); + } + return map; + }, [allBeads]); + + const allFilteredSelected = + filteredBeads.length > 0 && filteredBeads.every(b => selectedIds.has(b.bead_id)); + + const toggleSelectAll = () => { + if (allFilteredSelected) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(filteredBeads.map(b => b.bead_id))); + } + }; + + const toggleSelect = (beadId: string) => { + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(beadId)) { + next.delete(beadId); + } else { + next.add(beadId); + } + return next; + }); + }; + + const handleDeleteSelected = () => { + if (selectedIds.size === 0) return; + // Group by rigId — pick the first rig for simplicity (all selected beads share the same rig + // in most cases; if mixed, we use the first one and the mutation handles array input) + const selectedArr = [...selectedIds]; + const firstRigId = beadRigMap.get(selectedArr[0] ?? '') ?? ''; + setDeleteConfirm({ kind: 'selected', ids: selectedArr, rigId: firstRigId }); + }; + + const handleDeleteAllFailed = () => { + if (failedBeads.length === 0) return; + const rigIds = [...new Set(failedBeads.map(b => b.rigId))]; + setDeleteConfirm({ kind: 'all-failed', count: failedBeads.length, rigIds }); + }; + + const handleConfirmDelete = () => { + if (!deleteConfirm) return; + + if (deleteConfirm.kind === 'selected') { + const { ids, rigId } = deleteConfirm; + deleteBeadMutation.mutate({ rigId, beadId: ids, townId }); + } else { + // Delete all failed beads — collect all IDs and bulk-delete via the array endpoint. + // Using the first rig's ID for ownership verification; the town DO deletes by IDs. + const { rigIds } = deleteConfirm; + const firstRigId = rigIds[0]; + if (!firstRigId) return; + const failedIds = failedBeads.map(b => b.bead_id); + deleteBeadMutation.mutate({ rigId: firstRigId, beadId: failedIds, townId }); + } + }; + return (
{/* Header */} @@ -90,6 +190,17 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) {

Beads

{allBeads.length}
+ + {/* Delete all failed shortcut */} + {failedBeads.length > 0 && ( + + )}
{/* Filter bar */} @@ -127,6 +238,39 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) { + {/* Bulk action bar */} + + {selectedIds.size > 0 && ( + +
+ + {selectedIds.size} selected + + + +
+
+ )} +
+ {/* Bead list */}
{isLoading && ( @@ -153,6 +297,20 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) {
)} + {/* Select-all header row */} + {!isLoading && filteredBeads.length > 0 && ( +
+ + Select all ({filteredBeads.length}) +
+ )} + {filteredBeads.map((bead, i) => ( { - const rigId = (bead as Bead & { rigId: string }).rigId; - openDrawer({ type: 'bead', beadId: bead.bead_id, rigId }); - }} - className="group flex cursor-pointer items-center gap-3 border-b border-white/[0.04] px-6 py-2.5 transition-colors hover:bg-white/[0.02]" + className={`group flex cursor-pointer items-center gap-3 border-b border-white/[0.04] px-6 py-2.5 transition-colors hover:bg-white/[0.02] ${ + selectedIds.has(bead.bead_id) ? 'bg-white/[0.03]' : '' + }`} > + {/* Checkbox — stop propagation so clicking it doesn't open drawer */} + toggleSelect(bead.bead_id)} + onClick={e => e.stopPropagation()} + className="size-3.5 shrink-0 cursor-pointer accent-[oklch(95%_0.15_108)]" + aria-label={`Select bead ${bead.bead_id}`} + /> -
+
{ + openDrawer({ type: 'bead', beadId: bead.bead_id, rigId: bead.rigId }); + }} + >
{bead.title} @@ -180,7 +350,7 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) {
{bead.bead_id.slice(0, 8)} | - {(bead as Bead & { rigName: string }).rigName} + {bead.rigName} | {formatDistanceToNow(new Date(bead.created_at), { addSuffix: true })}
@@ -191,6 +361,30 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) {
+ {/* Delete confirmation dialog */} + { if (!open) setDeleteConfirm(null); }}> + + + + {deleteConfirm?.kind === 'all-failed' ? 'Delete all failed beads' : 'Delete beads'} + + + {deleteConfirm?.kind === 'all-failed' + ? `Delete ${deleteConfirm.count} failed bead${deleteConfirm.count === 1 ? '' : 's'}? This cannot be undone.` + : `Delete ${deleteConfirm?.ids.length ?? 0} selected bead${(deleteConfirm?.ids.length ?? 0) === 1 ? '' : 's'}? This cannot be undone.`} + + + + + + + + + {/* Drawers are rendered by the layout-level DrawerStackProvider */}
); diff --git a/apps/web/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx b/apps/web/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx index 2c071e4aa3..ffb7d1d446 100644 --- a/apps/web/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx +++ b/apps/web/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx @@ -22,8 +22,10 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; import Link from 'next/link'; import { formatDistanceToNow } from 'date-fns'; +import { Trash2 } from 'lucide-react'; const beadStatuses = ['open', 'in_progress', 'closed', 'failed'] as const; type BeadStatus = (typeof beadStatuses)[number]; @@ -46,11 +48,10 @@ const STATUS_COLORS: Record = { failed: 'bg-red-500/10 text-red-400 border-red-500/20', }; -type ConfirmAction = { - type: 'close' | 'fail'; - beadId: string; - title: string; -}; +type ConfirmAction = + | { type: 'close' | 'fail'; beadId: string; title: string } + | { type: 'bulk-delete'; beadIds: string[] } + | { type: 'delete-all-failed'; count: number }; export function BeadsTab({ townId }: { townId: string }) { const trpc = useTRPC(); @@ -59,6 +60,7 @@ export function BeadsTab({ townId }: { townId: string }) { const [statusFilter, setStatusFilter] = useState('all'); const [typeFilter, setTypeFilter] = useState('all'); const [confirmAction, setConfirmAction] = useState(null); + const [selectedIds, setSelectedIds] = useState>(new Set()); const beadsQuery = useQuery( trpc.admin.gastown.listBeads.queryOptions({ @@ -68,10 +70,14 @@ export function BeadsTab({ townId }: { townId: string }) { }) ); + const invalidateBeads = () => { + void queryClient.invalidateQueries(trpc.admin.gastown.listBeads.queryFilter({ townId })); + }; + const forceCloseMutation = useMutation( trpc.admin.gastown.forceCloseBead.mutationOptions({ onSuccess: () => { - void queryClient.invalidateQueries(trpc.admin.gastown.listBeads.queryFilter({ townId })); + invalidateBeads(); setConfirmAction(null); toast.success('Bead closed successfully'); }, @@ -84,7 +90,7 @@ export function BeadsTab({ townId }: { townId: string }) { const forceFailMutation = useMutation( trpc.admin.gastown.forceFailBead.mutationOptions({ onSuccess: () => { - void queryClient.invalidateQueries(trpc.admin.gastown.listBeads.queryFilter({ townId })); + invalidateBeads(); setConfirmAction(null); toast.success('Bead marked as failed'); }, @@ -94,17 +100,104 @@ export function BeadsTab({ townId }: { townId: string }) { }) ); + const bulkDeleteMutation = useMutation( + trpc.admin.gastown.bulkDeleteBeads.mutationOptions({ + onSuccess: data => { + invalidateBeads(); + setConfirmAction(null); + setSelectedIds(new Set()); + toast.success(`Deleted ${data.deleted} bead${data.deleted === 1 ? '' : 's'}`); + }, + onError: err => { + toast.error(`Failed to delete beads: ${err.message}`); + }, + }) + ); + + const deleteByStatusMutation = useMutation( + trpc.admin.gastown.deleteBeadsByStatus.mutationOptions({ + onSuccess: data => { + invalidateBeads(); + setConfirmAction(null); + setSelectedIds(new Set()); + toast.success(`Deleted ${data.deleted} bead${data.deleted === 1 ? '' : 's'}`); + }, + onError: err => { + toast.error(`Failed to delete beads: ${err.message}`); + }, + }) + ); + const handleConfirm = () => { if (!confirmAction) return; if (confirmAction.type === 'close') { forceCloseMutation.mutate({ townId, beadId: confirmAction.beadId }); - } else { + } else if (confirmAction.type === 'fail') { forceFailMutation.mutate({ townId, beadId: confirmAction.beadId }); + } else if (confirmAction.type === 'bulk-delete') { + bulkDeleteMutation.mutate({ townId, beadIds: confirmAction.beadIds }); + } else { + deleteByStatusMutation.mutate({ townId, status: 'failed', type: typeFilter === 'all' ? undefined : typeFilter }); } }; const beads = beadsQuery.data ?? []; - const isPending = forceCloseMutation.isPending || forceFailMutation.isPending; + const failedCount = beads.filter(b => b.status === 'failed').length; + + const allSelected = beads.length > 0 && beads.every(b => selectedIds.has(b.bead_id)); + + const toggleSelectAll = () => { + if (allSelected) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(beads.map(b => b.bead_id))); + } + }; + + const toggleSelect = (beadId: string) => { + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(beadId)) { + next.delete(beadId); + } else { + next.add(beadId); + } + return next; + }); + }; + + const isPending = + forceCloseMutation.isPending || + forceFailMutation.isPending || + bulkDeleteMutation.isPending || + deleteByStatusMutation.isPending; + + const confirmDialogTitle = () => { + if (!confirmAction) return ''; + if (confirmAction.type === 'close') return 'Force Close Bead'; + if (confirmAction.type === 'fail') return 'Force Fail Bead'; + if (confirmAction.type === 'bulk-delete') return 'Delete Beads'; + return 'Delete All Failed Beads'; + }; + + const confirmDialogDescription = () => { + if (!confirmAction) return ''; + if (confirmAction.type === 'close') { + return `This will force-close bead ${confirmAction.beadId.slice(0, 8)}…${confirmAction.title ? ` (${confirmAction.title})` : ''}. This action is logged in the audit trail.`; + } + if (confirmAction.type === 'fail') { + return `This will force-fail bead ${confirmAction.beadId.slice(0, 8)}…${confirmAction.title ? ` (${confirmAction.title})` : ''}. This action is logged in the audit trail.`; + } + if (confirmAction.type === 'bulk-delete') { + return `Delete ${confirmAction.beadIds.length} selected bead${confirmAction.beadIds.length === 1 ? '' : 's'}? This cannot be undone.`; + } + return `Delete ${confirmAction.count} failed bead${confirmAction.count === 1 ? '' : 's'}? This cannot be undone.`; + }; + + const isDestructiveConfirm = + confirmAction?.type === 'fail' || + confirmAction?.type === 'bulk-delete' || + confirmAction?.type === 'delete-all-failed'; return ( @@ -112,6 +205,19 @@ export function BeadsTab({ townId }: { townId: string }) {
Beads
+ {/* Delete all failed shortcut */} + {failedCount > 0 && ( + + )} +
+ + {/* Bulk action bar */} + {selectedIds.size > 0 && ( +
+ {selectedIds.size} selected + + +
+ )} {beadsQuery.isLoading && ( @@ -174,6 +306,13 @@ export function BeadsTab({ townId }: { townId: string }) { + @@ -185,6 +324,13 @@ export function BeadsTab({ townId }: { townId: string }) { {beads.map(bead => ( +
+ + Bead Type Status
+ toggleSelect(bead.bead_id)} + aria-label={`Select bead ${bead.bead_id}`} + /> + setConfirmAction(null)}> - - {confirmAction?.type === 'close' ? 'Force Close Bead' : 'Force Fail Bead'} - - - This will {confirmAction?.type === 'close' ? 'force-close' : 'force-fail'} bead{' '} - {confirmAction?.beadId.slice(0, 8)}… - {confirmAction?.title ? ` (${confirmAction.title})` : ''}. This action is logged in - the audit trail. - + {confirmDialogTitle()} + {confirmDialogDescription()} diff --git a/apps/web/src/routers/admin/gastown-router.ts b/apps/web/src/routers/admin/gastown-router.ts index 43dc72def8..eae3aa209f 100644 --- a/apps/web/src/routers/admin/gastown-router.ts +++ b/apps/web/src/routers/admin/gastown-router.ts @@ -755,6 +755,40 @@ export const adminGastownRouter = createTRPCRouter({ ); }), + bulkDeleteBeads: adminProcedure + .input(z.object({ townId: z.string().uuid(), beadIds: z.array(z.string().uuid()) })) + .output(z.object({ deleted: z.number() })) + .mutation(async ({ input, ctx }) => { + const result = await gastownTrpcMutate( + ctx.user, + 'gastown.adminBulkDeleteBeads', + { townId: input.townId, beadIds: input.beadIds }, + z.object({ deleted: z.number() }) + ); + return result ?? { deleted: 0 }; + }), + + deleteBeadsByStatus: adminProcedure + .input( + z.object({ + townId: z.string().uuid(), + status: z.enum(['open', 'in_progress', 'in_review', 'closed', 'failed']), + type: z + .enum(['issue', 'message', 'escalation', 'merge_request', 'convoy', 'molecule', 'agent']) + .optional(), + }) + ) + .output(z.object({ deleted: z.number() })) + .mutation(async ({ input, ctx }) => { + const result = await gastownTrpcMutate( + ctx.user, + 'gastown.adminDeleteBeadsByStatus', + { townId: input.townId, status: input.status, type: input.type }, + z.object({ deleted: z.number() }) + ); + return result ?? { deleted: 0 }; + }), + /** Force-retry a stalled review queue entry. Not yet implemented on the worker. */ forceRetryReview: adminProcedure .input(z.object({ townId: z.string().uuid(), entryId: z.string().uuid() })) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43918aad37..04df5eba8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1555,11 +1555,11 @@ importers: services/gastown/container: dependencies: '@kilocode/plugin': - specifier: 7.2.7 - version: 7.2.7 + specifier: 7.2.14 + version: 7.2.14 '@kilocode/sdk': - specifier: 7.2.7 - version: 7.2.7 + specifier: 7.2.14 + version: 7.2.14 hono: specifier: ^4.12.7 version: 4.12.8 @@ -4158,8 +4158,8 @@ packages: react: '*' react-native: '*' - '@kilocode/plugin@7.2.7': - resolution: {integrity: sha512-m4fHQlrUjuZTEABtOUIoHfUW3ejPpF8Jv2VhkFYVKJmuerltQBBFb4udrN97GJHMyoLL3nzJ6bNZkg3Czv8Z3g==} + '@kilocode/plugin@7.2.14': + resolution: {integrity: sha512-mS+WA9HZIBH2qQ9ARA+v0q4MdQTSdfOvKbe4AOSkjP+P5hVA70OM/UVM9DVcvmjSOxU+wuUxmOy+j/EQIrgFmw==} peerDependencies: '@opentui/core': '>=0.1.97' '@opentui/solid': '>=0.1.97' @@ -4172,8 +4172,8 @@ packages: '@kilocode/sdk@7.1.23': resolution: {integrity: sha512-moSKXqpwE+ozVbNE1aYIUb5Kd7fesOicRUn90WiMlp+8lRhqQc6ZTTIaIB9ZzD7Dak//4bSuo++bb+Jtw3U4Fg==} - '@kilocode/sdk@7.2.7': - resolution: {integrity: sha512-710n8PQ3QfmTwEdzOW2p7ur79rF9IBJk6nGsUu1hp8o/66RTkpVYC0EB7DKNfJ1NzTWmUqmhJZGWq4suCfADKQ==} + '@kilocode/sdk@7.2.14': + resolution: {integrity: sha512-Naz83lFrsbavuDp6UwxRuglOaSNvRBsZfcRNvb7RpWYAwbuJP0dBdhpXj6uO3ta5qxeQ2JzxKNC9Ffz+LCLLDg==} '@lottiefiles/dotlottie-react@0.17.15': resolution: {integrity: sha512-4wYAjsJhM28eUvJ/gT3KRM6fcyT7EM9n7PDrP71LaBTacc6bSN43qFTSJc1Li3QxUiraz23p0Q8EJBzXo8DsRw==} @@ -17671,14 +17671,14 @@ snapshots: react: 19.2.0 react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0) - '@kilocode/plugin@7.2.7': + '@kilocode/plugin@7.2.14': dependencies: - '@kilocode/sdk': 7.2.7 + '@kilocode/sdk': 7.2.14 zod: 4.1.8 '@kilocode/sdk@7.1.23': {} - '@kilocode/sdk@7.2.7': + '@kilocode/sdk@7.2.14': dependencies: cross-spawn: 7.0.6 diff --git a/services/gastown/container/plugin/client.ts b/services/gastown/container/plugin/client.ts index 6a222b7b41..3be276d24b 100644 --- a/services/gastown/container/plugin/client.ts +++ b/services/gastown/container/plugin/client.ts @@ -430,6 +430,30 @@ export class MayorGastownClient { }); } + async deleteBeads(rigId: string, beadIds: string[]): Promise<{ deleted: number }> { + return this.request<{ deleted: number }>( + this.mayorPath(`/rigs/${rigId}/beads/bulk-delete`), + { + method: 'POST', + body: JSON.stringify({ bead_ids: beadIds }), + } + ); + } + + async deleteBeadsByStatus( + rigId: string, + status: 'open' | 'in_progress' | 'in_review' | 'closed' | 'failed', + type?: string + ): Promise<{ deleted: number }> { + return this.request<{ deleted: number }>( + this.mayorPath(`/rigs/${rigId}/beads/delete-by-status`), + { + method: 'POST', + body: JSON.stringify({ status, ...(type ? { type } : {}) }), + } + ); + } + async resetAgent(rigId: string, agentId: string): Promise { await this.request(this.mayorPath(`/rigs/${rigId}/agents/${agentId}/reset`), { method: 'POST', diff --git a/services/gastown/container/plugin/mayor-tools.ts b/services/gastown/container/plugin/mayor-tools.ts index 09b28ded47..bff2459451 100644 --- a/services/gastown/container/plugin/mayor-tools.ts +++ b/services/gastown/container/plugin/mayor-tools.ts @@ -337,14 +337,22 @@ export function createMayorTools(client: MayorGastownClient) { }), gt_bead_delete: tool({ - description: 'Delete a bead. Use with caution — this is irreversible.', + description: + 'Delete one or more beads. Use with caution — this is irreversible. Pass a single UUID string or an array of UUIDs to bulk-delete up to 5000 at once.', 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'), + rig_id: tool.schema.string().describe('The UUID of the rig the bead(s) belong to'), + bead_id: tool.schema + .union([tool.schema.string(), tool.schema.array(tool.schema.string())]) + .describe('A single bead UUID or an array of bead UUIDs to delete'), }, async execute(args) { - await client.deleteBead(args.rig_id, args.bead_id); - return `Bead ${args.bead_id} deleted.`; + const ids = Array.isArray(args.bead_id) ? args.bead_id : [args.bead_id]; + if (ids.length === 1 && ids[0]) { + await client.deleteBead(args.rig_id, ids[0]); + return `Bead ${ids[0]} deleted.`; + } + const result = await client.deleteBeads(args.rig_id, ids); + return `Deleted ${result.deleted} beads.`; }, }), diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index b7d0e475d4..1fcac8974e 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -1134,6 +1134,34 @@ export class TownDO extends DurableObject { beadOps.deleteBead(this.sql, beadId); } + async deleteBeads(beadIds: string[]): Promise { + return beadOps.deleteBeads(this.sql, beadIds); + } + + async deleteBeadsByStatus( + status: BeadStatusType, + type?: BeadTypeType, + rigId?: string + ): Promise { + if (rigId) { + const rigBeads = BeadRecord.pick({ bead_id: true }) + .array() + .parse([ + ...query( + this.sql, + /* sql */ `SELECT ${beads.bead_id} FROM ${beads} WHERE ${beads.rig_id} = ? AND ${beads.status} = ?${type ? ` AND ${beads.type} = ?` : ''}`, + type ? [rigId, status, type] : [rigId, status] + ), + ]); + if (rigBeads.length === 0) return 0; + return beadOps.deleteBeads( + this.sql, + rigBeads.map(r => r.bead_id) + ); + } + return beadOps.deleteBeadsByStatus(this.sql, status, type); + } + async listBeadEvents(options: { beadId?: string; since?: string; diff --git a/services/gastown/src/dos/town/beads.ts b/services/gastown/src/dos/town/beads.ts index 1f1409c337..efb2f8027e 100644 --- a/services/gastown/src/dos/town/beads.ts +++ b/services/gastown/src/dos/town/beads.ts @@ -715,6 +715,144 @@ export function deleteBead(sql: SqlStorage, beadId: string): void { query(sql, /* sql */ `DELETE FROM ${beads} WHERE ${beads.bead_id} = ?`, [beadId]); } +export function deleteBeads(sql: SqlStorage, beadIds: string[]): number { + if (beadIds.length === 0) return 0; + + const allIds = new Set(beadIds); + + // Expand with child beads (molecule steps, etc.) + const childRows = [ + ...query( + sql, + /* sql */ `SELECT ${beads.bead_id} FROM ${beads} WHERE ${beads.parent_bead_id} IN (${beadIds.map(() => '?').join(',')})`, + [...beadIds] + ), + ]; + const childIds = BeadRecord.pick({ bead_id: true }) + .array() + .parse(childRows) + .map(r => r.bead_id); + + // Recursively collect children of children + if (childIds.length > 0) { + for (const childId of childIds) { + allIds.add(childId); + } + // Recurse for deeper nesting + const deeperIds = collectChildBeadIds(sql, childIds); + for (const id of deeperIds) { + allIds.add(id); + } + } + + const allIdsArr = [...allIds]; + const placeholders = allIdsArr.map(() => '?').join(','); + + // Unhook agents assigned to any of these beads + query( + sql, + /* sql */ ` + UPDATE ${agent_metadata} + SET ${agent_metadata.columns.current_hook_bead_id} = NULL, + ${agent_metadata.columns.status} = 'idle' + WHERE ${agent_metadata.current_hook_bead_id} IN (${placeholders}) + `, + [...allIdsArr] + ); + + // Delete dependencies referencing any of these beads + query( + sql, + /* sql */ `DELETE FROM ${bead_dependencies} WHERE ${bead_dependencies.bead_id} IN (${placeholders}) OR ${bead_dependencies.depends_on_bead_id} IN (${placeholders})`, + [...allIdsArr, ...allIdsArr] + ); + + // Delete events + query( + sql, + /* sql */ `DELETE FROM ${bead_events} WHERE ${bead_events.bead_id} IN (${placeholders})`, + [...allIdsArr] + ); + + // Delete satellite metadata + query( + sql, + /* sql */ `DELETE FROM ${agent_metadata} WHERE ${agent_metadata.bead_id} IN (${placeholders})`, + [...allIdsArr] + ); + query( + sql, + /* sql */ `DELETE FROM ${review_metadata} WHERE ${review_metadata.bead_id} IN (${placeholders})`, + [...allIdsArr] + ); + query( + sql, + /* sql */ `DELETE FROM ${escalation_metadata} WHERE ${escalation_metadata.bead_id} IN (${placeholders})`, + [...allIdsArr] + ); + query( + sql, + /* sql */ `DELETE FROM ${convoy_metadata} WHERE ${convoy_metadata.bead_id} IN (${placeholders})`, + [...allIdsArr] + ); + + // Delete the beads themselves + query( + sql, + /* sql */ `DELETE FROM ${beads} WHERE ${beads.bead_id} IN (${placeholders})`, + [...allIdsArr] + ); + + return allIdsArr.length; +} + +function collectChildBeadIds(sql: SqlStorage, parentIds: string[]): string[] { + if (parentIds.length === 0) return []; + const childRows = [ + ...query( + sql, + /* sql */ `SELECT ${beads.bead_id} FROM ${beads} WHERE ${beads.parent_bead_id} IN (${parentIds.map(() => '?').join(',')})`, + [...parentIds] + ), + ]; + const childIds = BeadRecord.pick({ bead_id: true }) + .array() + .parse(childRows) + .map(r => r.bead_id); + if (childIds.length === 0) return []; + const deeperIds = collectChildBeadIds(sql, childIds); + return [...childIds, ...deeperIds]; +} + +export function deleteBeadsByStatus( + sql: SqlStorage, + status: BeadStatus, + type?: BeadType +): number { + const conditions: string[] = [`${beads.status} = ?`]; + const values: unknown[] = [status]; + + if (type) { + conditions.push(`${beads.type} = ?`); + values.push(type); + } + + const rows = [ + ...query( + sql, + /* sql */ `SELECT ${beads.bead_id} FROM ${beads} WHERE ${conditions.join(' AND ')}`, + values + ), + ]; + const beadIds = BeadRecord.pick({ bead_id: true }) + .array() + .parse(rows) + .map(r => r.bead_id); + + if (beadIds.length === 0) return 0; + return deleteBeads(sql, beadIds); +} + // ── Bead Events ───────────────────────────────────────────────────── export function logBeadEvent( diff --git a/services/gastown/src/gastown.worker.ts b/services/gastown/src/gastown.worker.ts index 2e918ea215..6f3b3aae6c 100644 --- a/services/gastown/src/gastown.worker.ts +++ b/services/gastown/src/gastown.worker.ts @@ -113,6 +113,8 @@ import { handleMayorConvoyClose, handleMayorConvoyUpdate, handleMayorBeadDelete, + handleMayorBulkDeleteBeads, + handleMayorDeleteBeadsByStatus, handleMayorEscalationAcknowledge, handleMayorConvoyStart, handleMayorUiAction, @@ -978,6 +980,16 @@ app.delete('/api/mayor/:townId/tools/rigs/:rigId/beads/:beadId', c => handleMayorBeadDelete(c, c.req.param()) ) ); +app.post('/api/mayor/:townId/tools/rigs/:rigId/beads/bulk-delete', c => + instrumented(c, 'POST /api/mayor/:townId/tools/rigs/:rigId/beads/bulk-delete', () => + handleMayorBulkDeleteBeads(c, c.req.param()) + ) +); +app.post('/api/mayor/:townId/tools/rigs/:rigId/beads/delete-by-status', c => + instrumented(c, 'POST /api/mayor/:townId/tools/rigs/:rigId/beads/delete-by-status', () => + handleMayorDeleteBeadsByStatus(c, c.req.param()) + ) +); app.post('/api/mayor/:townId/tools/rigs/:rigId/agents/:agentId/reset', c => instrumented(c, 'POST /api/mayor/:townId/tools/rigs/:rigId/agents/:agentId/reset', () => handleMayorAgentReset(c, c.req.param()) diff --git a/services/gastown/src/handlers/mayor-tools.handler.ts b/services/gastown/src/handlers/mayor-tools.handler.ts index fcfe207ce3..852bf96391 100644 --- a/services/gastown/src/handlers/mayor-tools.handler.ts +++ b/services/gastown/src/handlers/mayor-tools.handler.ts @@ -635,6 +635,69 @@ export async function handleMayorBeadDelete( return c.json(resSuccess({ deleted: true })); } +const MayorBulkDeleteBeadsBody = z.object({ + bead_ids: z.array(z.string().uuid()).min(1).max(5000), +}); + +export async function handleMayorBulkDeleteBeads( + c: Context, + params: { townId: string; rigId: string } +) { + const rigOwned = await verifyRigBelongsToTown(c, params.townId, params.rigId); + if (!rigOwned) { + return c.json(resError('Rig not found in this town'), 403); + } + + const parsed = await parseJsonBody(c, MayorBulkDeleteBeadsBody); + if (!parsed.success) { + return c.json(resError('Invalid request body', parsed.error), 400); + } + + const { bead_ids } = parsed.data; + + console.log( + `${HANDLER_LOG} handleMayorBulkDeleteBeads: townId=${params.townId} rigId=${params.rigId} count=${bead_ids.length}` + ); + + const town = getTownDOStub(c.env, params.townId); + const count = await town.deleteBeads(bead_ids); + + return c.json(resSuccess({ deleted: count })); +} + +const MayorDeleteBeadsByStatusBody = z.object({ + status: z.enum(['open', 'in_progress', 'in_review', 'closed', 'failed']), + type: z + .enum(['issue', 'message', 'escalation', 'merge_request', 'convoy', 'molecule', 'agent']) + .optional(), +}); + +export async function handleMayorDeleteBeadsByStatus( + c: Context, + params: { townId: string; rigId: string } +) { + const rigOwned = await verifyRigBelongsToTown(c, params.townId, params.rigId); + if (!rigOwned) { + return c.json(resError('Rig not found in this town'), 403); + } + + const parsed = await parseJsonBody(c, MayorDeleteBeadsByStatusBody); + if (!parsed.success) { + return c.json(resError('Invalid request body', parsed.error), 400); + } + + const { status, type } = parsed.data; + + console.log( + `${HANDLER_LOG} handleMayorDeleteBeadsByStatus: townId=${params.townId} rigId=${params.rigId} status=${status}${type ? ` type=${type}` : ''}` + ); + + const town = getTownDOStub(c.env, params.townId); + const count = await town.deleteBeadsByStatus(status, type, params.rigId); + + return c.json(resSuccess({ deleted: count })); +} + /** * POST /api/mayor/:townId/tools/escalations/:escalationId/acknowledge * Acknowledge an escalation, marking it as reviewed. diff --git a/services/gastown/src/prompts/mayor-system.prompt.ts b/services/gastown/src/prompts/mayor-system.prompt.ts index 78c3b6d347..b0bc7d8e1d 100644 --- a/services/gastown/src/prompts/mayor-system.prompt.ts +++ b/services/gastown/src/prompts/mayor-system.prompt.ts @@ -214,7 +214,7 @@ You can directly edit town state when things go wrong: - **gt_agent_reset** to force-reset a stuck agent to idle - **gt_convoy_close** to force-close a stuck convoy - **gt_convoy_update** to edit convoy merge_mode or feature_branch -- **gt_bead_delete** to remove beads that shouldn't exist +- **gt_bead_delete** to remove beads that shouldn't exist — accepts a single UUID or an array of UUIDs to bulk-delete up to 5000 at once - **gt_escalation_acknowledge** to acknowledge escalations Use these tools when the user reports stuck state, when you detect problems during delegation, or when you need to clean up after failures. You are the town coordinator — you have full authority over the control plane. diff --git a/services/gastown/src/trpc/router.ts b/services/gastown/src/trpc/router.ts index 12aab73298..603691b770 100644 --- a/services/gastown/src/trpc/router.ts +++ b/services/gastown/src/trpc/router.ts @@ -650,14 +650,21 @@ export const gastownRouter = router({ .input( z.object({ rigId: z.string().uuid(), - beadId: z.string().uuid(), + beadId: z.union([z.string().uuid(), z.array(z.string().uuid())]), townId: z.string().uuid().optional(), }) ) + .output(z.object({ deleted: z.number() })) .mutation(async ({ ctx, input }) => { const rig = await verifyRigOwnership(ctx.env, ctx, input.rigId, input.townId); const townStub = getTownDOStub(ctx.env, rig.town_id); - await townStub.deleteBead(input.beadId); + const ids = Array.isArray(input.beadId) ? input.beadId : [input.beadId]; + if (ids.length === 1) { + await townStub.deleteBead(ids[0]); + return { deleted: 1 }; + } + const count = await townStub.deleteBeads(ids); + return { deleted: count }; }), updateBead: gastownProcedure @@ -707,6 +714,25 @@ export const gastownRouter = router({ return townStub.updateBead(beadId, fields, ctx.userId); }), + deleteBeadsByStatus: gastownProcedure + .input( + z.object({ + rigId: z.string().uuid(), + status: z.enum(['open', 'in_progress', 'in_review', 'closed', 'failed']), + type: z + .enum(['issue', 'message', 'escalation', 'merge_request', 'convoy', 'molecule', 'agent']) + .optional(), + townId: z.string().uuid().optional(), + }) + ) + .output(z.object({ deleted: z.number() })) + .mutation(async ({ ctx, input }) => { + const rig = await verifyRigOwnership(ctx.env, ctx, input.rigId, input.townId); + const townStub = getTownDOStub(ctx.env, rig.town_id); + const count = await townStub.deleteBeadsByStatus(input.status, input.type, rig.id); + return { deleted: count }; + }), + // ── Agents ────────────────────────────────────────────────────────── listAgents: gastownProcedure @@ -1592,6 +1618,37 @@ export const gastownRouter = router({ return townStub.getBeadAsync(input.beadId); }), + adminBulkDeleteBeads: adminProcedure + .input( + z.object({ + townId: z.string().uuid(), + beadIds: z.array(z.string().uuid()), + }) + ) + .output(z.object({ deleted: z.number() })) + .mutation(async ({ ctx, input }) => { + const townStub = getTownDOStub(ctx.env, input.townId); + const count = await townStub.deleteBeads(input.beadIds); + return { deleted: count }; + }), + + adminDeleteBeadsByStatus: adminProcedure + .input( + z.object({ + townId: z.string().uuid(), + status: z.enum(['open', 'in_progress', 'in_review', 'closed', 'failed']), + type: z + .enum(['issue', 'message', 'escalation', 'merge_request', 'convoy', 'molecule', 'agent']) + .optional(), + }) + ) + .output(z.object({ deleted: z.number() })) + .mutation(async ({ ctx, input }) => { + const townStub = getTownDOStub(ctx.env, input.townId); + const count = await townStub.deleteBeadsByStatus(input.status, input.type); + return { deleted: count }; + }), + // DEBUG: raw agent_metadata dump — remove after debugging debugAgentMetadata: adminProcedure .input(z.object({ townId: z.string().uuid() })) From 93cbb37bcacf1da212830b8948d9a372bdd36b2c Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Mon, 20 Apr 2026 09:11:04 -0500 Subject: [PATCH 20/33] chore(gastown): fix prod container image ref to Dockerfile; update pnpm-lock --- services/gastown/wrangler.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/gastown/wrangler.jsonc b/services/gastown/wrangler.jsonc index 28fa5fce54..5c1c309c57 100644 --- a/services/gastown/wrangler.jsonc +++ b/services/gastown/wrangler.jsonc @@ -35,7 +35,7 @@ "containers": [ { "class_name": "TownContainerDO", - "image": "./container/Dockerfile.dev", + "image": "./container/Dockerfile", "instance_type": "standard-4", "max_instances": 810, }, From 83e42920cf0a944b2965c10cc89a7cc35e063695 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Mon, 20 Apr 2026 14:27:43 +0000 Subject: [PATCH 21/33] fix(gastown): address PR #2374 review comments - Fix 1: Skip pr_url guard for review-then-land convoys (direct-merge strategy never creates per-bead PRs, so landing MR should be created as long as all tracked beads are closed) - Fix 2: Add GASTOWN_TOWN_ID and GASTOWN_RIG_ID to RESERVED_ENV_KEYS in control-server.ts to prevent user env_vars from clobbering runtime routing vars used by pending-nudge routes and plugin clients - Fix 3: Filter RESERVED_ENV_KEYS when building hotSwapEnv in process-manager.ts; export RESERVED_ENV_KEYS from control-server so it can be shared with the process manager - Fix 4: Route disabled-auto-resolve merge conflicts through the full escalation pipeline (escalation_metadata row + triage request) so escalations appear in the UI and trigger automated follow-up - Fix 5: Import BeadType as BeadTypeType in Town.do.ts and use BeadStatus (already imported) instead of undefined BeadStatusType in the deleteBeadsByStatus method signature --- .../gastown/container/src/control-server.ts | 6 +- .../gastown/container/src/process-manager.ts | 8 +- services/gastown/src/dos/Town.do.ts | 3 +- services/gastown/src/dos/town/reconciler.ts | 100 +++++++++++++----- 4 files changed, 83 insertions(+), 34 deletions(-) diff --git a/services/gastown/container/src/control-server.ts b/services/gastown/container/src/control-server.ts index de43a95463..29ba6e15ee 100644 --- a/services/gastown/container/src/control-server.ts +++ b/services/gastown/container/src/control-server.ts @@ -49,7 +49,7 @@ let lastAppliedEnvVarKeys = new Set(); // Env keys managed by the control plane that custom env_vars must never override. // If a custom key collides with a reserved key, the infra value wins and the // custom value is silently ignored — matching the !(key in env) guard in buildAgentEnv. -const RESERVED_ENV_KEYS = new Set([ +export const RESERVED_ENV_KEYS = new Set([ 'KILOCODE_TOKEN', 'GIT_TOKEN', 'GITHUB_TOKEN', @@ -64,6 +64,10 @@ const RESERVED_ENV_KEYS = new Set([ 'GASTOWN_CONTAINER_TOKEN', 'GASTOWN_SESSION_TOKEN', 'GASTOWN_API_URL', + // Runtime routing vars read by pending-nudge routes and plugin clients — + // must never be overwritten by user-supplied env_vars. + 'GASTOWN_TOWN_ID', + 'GASTOWN_RIG_ID', ]); /** Get the latest town config delivered via X-Town-Config header. */ diff --git a/services/gastown/container/src/process-manager.ts b/services/gastown/container/src/process-manager.ts index 659a2c2dea..8262926e90 100644 --- a/services/gastown/container/src/process-manager.ts +++ b/services/gastown/container/src/process-manager.ts @@ -12,7 +12,7 @@ import * as fs from 'node:fs/promises'; import type { ManagedAgent, StartAgentRequest } from './types'; import { reportAgentCompleted, reportMayorWaiting } from './completion-reporter'; import { buildKiloConfigContent } from './agent-runner'; -import { getCurrentTownConfig, getLastAppliedEnvVarKeys } from './control-server'; +import { getCurrentTownConfig, getLastAppliedEnvVarKeys, RESERVED_ENV_KEYS } from './control-server'; import { log } from './logger'; const MANAGER_LOG = '[process-manager]'; @@ -1267,14 +1267,16 @@ export async function updateAgentModel( // Overlay custom env_vars from the town config so hot-swap picks up // values that were added/changed after the initial dispatch. Infra - // keys in LIVE_ENV_KEYS always take precedence (they were already - // populated from process.env above), so custom vars cannot override. + // keys in LIVE_ENV_KEYS and RESERVED_ENV_KEYS always take precedence + // (LIVE_ENV_KEYS were already populated from process.env above; + // RESERVED_ENV_KEYS are runtime routing vars that must never be clobbered). const freshConfig = getCurrentTownConfig(); const freshEnvVars = freshConfig?.env_vars; const freshCustomKeySet = new Set(); if (freshEnvVars !== null && typeof freshEnvVars === 'object' && !Array.isArray(freshEnvVars)) { for (const [key, value] of Object.entries(freshEnvVars as Record)) { if (LIVE_ENV_KEYS.has(key)) continue; + if (RESERVED_ENV_KEYS.has(key)) continue; freshCustomKeySet.add(key); if (value !== undefined && value !== null) { hotSwapEnv[key] = String(value); diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index 1fcac8974e..60cd8bcd3d 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -72,6 +72,7 @@ import type { BeadFilter, Bead, BeadStatus, + BeadType as BeadTypeType, BeadPriority as BeadPriorityType, RegisterAgentInput, AgentFilter, @@ -1139,7 +1140,7 @@ export class TownDO extends DurableObject { } async deleteBeadsByStatus( - status: BeadStatusType, + status: BeadStatus, type?: BeadTypeType, rigId?: string ): Promise { diff --git a/services/gastown/src/dos/town/reconciler.ts b/services/gastown/src/dos/town/reconciler.ts index f4753a4b60..75058b7c2e 100644 --- a/services/gastown/src/dos/town/reconciler.ts +++ b/services/gastown/src/dos/town/reconciler.ts @@ -18,12 +18,14 @@ import { review_metadata, ReviewMetadataRecord } from '../../db/tables/review-me import { convoy_metadata, ConvoyMetadataRecord } from '../../db/tables/convoy-metadata.table'; import { bead_dependencies } from '../../db/tables/bead-dependencies.table'; import { agent_nudges } from '../../db/tables/agent-nudges.table'; +import { escalation_metadata } from '../../db/tables/escalation-metadata.table'; import { query } from '../../util/query.util'; import { GUPP_ESCALATE_MS, GUPP_FORCE_STOP_MS, AGENT_GC_RETENTION_MS, TRIAGE_LABEL_LIKE, + createTriageRequest, } from './patrol'; import { MAX_DISPATCH_ATTEMPTS } from './scheduling'; import * as reviewQueue from './review-queue'; @@ -544,12 +546,16 @@ export function applyEvent( // Conflict bead blocks the MR bead (same pattern as feedback beads) beadOps.insertDependency(sql, mrBeadId, conflictBead.bead_id, 'blocks'); } else { - // auto_resolve_merge_conflicts disabled — create an escalation bead - beadOps.createBead(sql, { + // auto_resolve_merge_conflicts disabled — route through the full + // escalation pipeline so escalation_metadata, triage request, and + // mayor notification are all created (same path as routeEscalation()). + const escalationBead = beadOps.createBead(sql, { type: 'escalation', title: `Merge conflict detected: ${branch}`, body: `PR ${prUrl} (branch ${branch}) has merge conflicts that require manual resolution.`, priority: 'high', + rig_id: mrBead.rig_id ?? undefined, + labels: ['gt:escalation', 'severity:high'], metadata: { pr_url: prUrl, branch, @@ -559,6 +565,36 @@ export function applyEvent( conflict: true, }, }); + query( + sql, + /* sql */ ` + INSERT INTO ${escalation_metadata} ( + ${escalation_metadata.columns.bead_id}, + ${escalation_metadata.columns.severity}, + ${escalation_metadata.columns.category}, + ${escalation_metadata.columns.acknowledged}, + ${escalation_metadata.columns.re_escalation_count}, + ${escalation_metadata.columns.acknowledged_at} + ) VALUES (?, ?, ?, ?, ?, ?) + `, + [escalationBead.bead_id, 'high', 'merge_conflict', 0, 0, null] + ); + createTriageRequest(sql, { + triageType: 'escalation', + agentBeadId: null, + title: `Escalation (high): Merge conflict on ${branch}`, + context: { + escalation_bead_id: escalationBead.bead_id, + severity: 'high', + rig_id: mrBead.rig_id, + category: 'merge_conflict', + pr_url: prUrl, + branch, + mr_bead_id: mrBeadId, + }, + options: ['ESCALATE_TO_MAYOR', 'RESTART', 'CLOSE_BEAD', 'REASSIGN_BEAD'], + rigId: mrBead.rig_id ?? undefined, + }); } return; } @@ -1990,34 +2026,40 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { if (elapsed < cooldownMs) continue; } - // Fix 3 (#2260): Check that tracked beads have at least one MR with a PR URL - const convoyBeadsWithPr = z - .object({ cnt: z.number() }) - .array() - .parse([ - ...query( - sql, - /* sql */ ` - SELECT count(*) as cnt - FROM ${bead_dependencies} track_dep - INNER JOIN ${bead_dependencies} mr_dep - ON mr_dep.${bead_dependencies.columns.depends_on_bead_id} = track_dep.${bead_dependencies.columns.bead_id} - INNER JOIN ${review_metadata} rm - ON rm.${review_metadata.columns.bead_id} = mr_dep.${bead_dependencies.columns.bead_id} - WHERE track_dep.${bead_dependencies.columns.depends_on_bead_id} = ? - AND track_dep.${bead_dependencies.columns.dependency_type} = 'tracks' - AND mr_dep.${bead_dependencies.columns.dependency_type} = 'tracks' - AND rm.${review_metadata.columns.pr_url} IS NOT NULL - `, - [convoy.bead_id] - ), - ]); + // Fix 3 (#2260): Check that tracked beads have at least one MR with a PR URL. + // For review-then-land convoys using direct merge strategy, intermediate bead + // merges go straight into the feature branch without persisting a pr_url — + // skip this guard and always create the landing MR when all beads are closed. + const needsPrUrl = convoy.merge_mode !== 'review-then-land'; + if (needsPrUrl) { + const convoyBeadsWithPr = z + .object({ cnt: z.number() }) + .array() + .parse([ + ...query( + sql, + /* sql */ ` + SELECT count(*) as cnt + FROM ${bead_dependencies} track_dep + INNER JOIN ${bead_dependencies} mr_dep + ON mr_dep.${bead_dependencies.columns.depends_on_bead_id} = track_dep.${bead_dependencies.columns.bead_id} + INNER JOIN ${review_metadata} rm + ON rm.${review_metadata.columns.bead_id} = mr_dep.${bead_dependencies.columns.bead_id} + WHERE track_dep.${bead_dependencies.columns.depends_on_bead_id} = ? + AND track_dep.${bead_dependencies.columns.dependency_type} = 'tracks' + AND mr_dep.${bead_dependencies.columns.dependency_type} = 'tracks' + AND rm.${review_metadata.columns.pr_url} IS NOT NULL + `, + [convoy.bead_id] + ), + ]); - if ((convoyBeadsWithPr[0]?.cnt ?? 0) === 0) { - console.warn( - `${LOG} convoy ${convoy.bead_id} has no beads with pr_url — skipping create_landing_mr` - ); - continue; + if ((convoyBeadsWithPr[0]?.cnt ?? 0) === 0) { + console.warn( + `${LOG} convoy ${convoy.bead_id} has no beads with pr_url — skipping create_landing_mr` + ); + continue; + } } // No landing MR exists yet and cooldown has passed — create one From c0a0b987264dcb557adf9aa23daaaff5ad335e0f Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Mon, 20 Apr 2026 10:59:23 -0500 Subject: [PATCH 22/33] fix: resolve type errors across gastown-staging branch - BeadsPageClient: loop individual deleteBead calls (no bulk endpoint) - router.d.ts: add auto_resolve_merge_conflicts to all 10 type positions - BeadsTab: explicit union narrowing for delete-all-failed action - polecat-system.prompt: escape backticks inside template literal - beads.ts/Town.do.ts: use sql.exec for dynamic IN queries (tsgo can't infer placeholder count from runtime-built strings) - mayor-tools.handler: fix parseJsonBody/resError call signatures --- .../[townId]/beads/BeadsPageClient.tsx | 15 ++-- .../admin/gastown/towns/[townId]/BeadsTab.tsx | 5 +- apps/web/src/lib/gastown/types/router.d.ts | 10 +++ services/gastown/src/dos/Town.do.ts | 5 +- services/gastown/src/dos/town/beads.ts | 72 ++++++++----------- .../src/handlers/mayor-tools.handler.ts | 8 +-- .../src/prompts/polecat-system.prompt.ts | 12 ++-- 7 files changed, 63 insertions(+), 64 deletions(-) diff --git a/apps/web/src/app/(app)/gastown/[townId]/beads/BeadsPageClient.tsx b/apps/web/src/app/(app)/gastown/[townId]/beads/BeadsPageClient.tsx index c2b1f8304e..0624c0c01e 100644 --- a/apps/web/src/app/(app)/gastown/[townId]/beads/BeadsPageClient.tsx +++ b/apps/web/src/app/(app)/gastown/[townId]/beads/BeadsPageClient.tsx @@ -168,15 +168,14 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) { if (deleteConfirm.kind === 'selected') { const { ids, rigId } = deleteConfirm; - deleteBeadMutation.mutate({ rigId, beadId: ids, townId }); + for (const id of ids) { + deleteBeadMutation.mutate({ rigId, beadId: id, townId }); + } } else { - // Delete all failed beads — collect all IDs and bulk-delete via the array endpoint. - // Using the first rig's ID for ownership verification; the town DO deletes by IDs. - const { rigIds } = deleteConfirm; - const firstRigId = rigIds[0]; - if (!firstRigId) return; - const failedIds = failedBeads.map(b => b.bead_id); - deleteBeadMutation.mutate({ rigId: firstRigId, beadId: failedIds, townId }); + // Delete all failed beads one by one (no bulk endpoint). + for (const bead of failedBeads) { + deleteBeadMutation.mutate({ rigId: bead.rigId, beadId: bead.bead_id, townId }); + } } }; diff --git a/apps/web/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx b/apps/web/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx index ffb7d1d446..f7e6dd6b25 100644 --- a/apps/web/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx +++ b/apps/web/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx @@ -191,7 +191,10 @@ export function BeadsTab({ townId }: { townId: string }) { if (confirmAction.type === 'bulk-delete') { return `Delete ${confirmAction.beadIds.length} selected bead${confirmAction.beadIds.length === 1 ? '' : 's'}? This cannot be undone.`; } - return `Delete ${confirmAction.count} failed bead${confirmAction.count === 1 ? '' : 's'}? This cannot be undone.`; + if (confirmAction.type === 'delete-all-failed') { + return `Delete ${confirmAction.count} failed bead${confirmAction.count === 1 ? '' : 's'}? This cannot be undone.`; + } + return ''; }; const isDestructiveConfirm = diff --git a/apps/web/src/lib/gastown/types/router.d.ts b/apps/web/src/lib/gastown/types/router.d.ts index c5e2d76067..da9eff6d5f 100644 --- a/apps/web/src/lib/gastown/types/router.d.ts +++ b/apps/web/src/lib/gastown/types/router.d.ts @@ -138,6 +138,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< review_mode?: 'rework' | 'comments' | undefined; code_review?: boolean | undefined; auto_resolve_pr_feedback?: boolean | undefined; + auto_resolve_merge_conflicts?: boolean | undefined; auto_merge_delay_minutes?: number | null | undefined; merge_strategy?: 'direct' | 'pr' | undefined; convoy_merge_mode?: 'review-then-land' | 'review-and-merge' | undefined; @@ -209,6 +210,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< review_mode?: 'rework' | 'comments' | undefined; code_review?: boolean | undefined; auto_resolve_pr_feedback?: boolean | undefined; + auto_resolve_merge_conflicts?: boolean | undefined; auto_merge_delay_minutes?: number | null | undefined; merge_strategy?: 'direct' | 'pr' | undefined; convoy_merge_mode?: 'review-then-land' | 'review-and-merge' | undefined; @@ -555,6 +557,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< code_review: boolean; review_mode: 'comments' | 'rework'; auto_resolve_pr_feedback: boolean; + auto_resolve_merge_conflicts: boolean; auto_merge_delay_minutes: number | null; } | undefined; @@ -619,6 +622,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< code_review?: boolean | undefined; review_mode?: 'comments' | 'rework' | undefined; auto_resolve_pr_feedback?: boolean | undefined; + auto_resolve_merge_conflicts?: boolean | undefined; auto_merge_delay_minutes?: number | null | undefined; } | undefined; @@ -677,6 +681,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< code_review: boolean; review_mode: 'comments' | 'rework'; auto_resolve_pr_feedback: boolean; + auto_resolve_merge_conflicts: boolean; auto_merge_delay_minutes: number | null; } | undefined; @@ -1537,6 +1542,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute review_mode?: 'rework' | 'comments' | undefined; code_review?: boolean | undefined; auto_resolve_pr_feedback?: boolean | undefined; + auto_resolve_merge_conflicts?: boolean | undefined; auto_merge_delay_minutes?: number | null | undefined; merge_strategy?: 'direct' | 'pr' | undefined; convoy_merge_mode?: 'review-then-land' | 'review-and-merge' | undefined; @@ -1608,6 +1614,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute review_mode?: 'rework' | 'comments' | undefined; code_review?: boolean | undefined; auto_resolve_pr_feedback?: boolean | undefined; + auto_resolve_merge_conflicts?: boolean | undefined; auto_merge_delay_minutes?: number | null | undefined; merge_strategy?: 'direct' | 'pr' | undefined; convoy_merge_mode?: 'review-then-land' | 'review-and-merge' | undefined; @@ -1954,6 +1961,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute code_review: boolean; review_mode: 'comments' | 'rework'; auto_resolve_pr_feedback: boolean; + auto_resolve_merge_conflicts: boolean; auto_merge_delay_minutes: number | null; } | undefined; @@ -2018,6 +2026,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute code_review?: boolean | undefined; review_mode?: 'comments' | 'rework' | undefined; auto_resolve_pr_feedback?: boolean | undefined; + auto_resolve_merge_conflicts?: boolean | undefined; auto_merge_delay_minutes?: number | null | undefined; } | undefined; @@ -2076,6 +2085,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute code_review: boolean; review_mode: 'comments' | 'rework'; auto_resolve_pr_feedback: boolean; + auto_resolve_merge_conflicts: boolean; auto_merge_delay_minutes: number | null; } | undefined; diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index 60cd8bcd3d..64625cd545 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -1148,10 +1148,9 @@ export class TownDO extends DurableObject { const rigBeads = BeadRecord.pick({ bead_id: true }) .array() .parse([ - ...query( - this.sql, + ...this.sql.exec( /* sql */ `SELECT ${beads.bead_id} FROM ${beads} WHERE ${beads.rig_id} = ? AND ${beads.status} = ?${type ? ` AND ${beads.type} = ?` : ''}`, - type ? [rigId, status, type] : [rigId, status] + ...(type ? [rigId, status, type] : [rigId, status]) ), ]); if (rigBeads.length === 0) return 0; diff --git a/services/gastown/src/dos/town/beads.ts b/services/gastown/src/dos/town/beads.ts index efb2f8027e..ae5df9eba5 100644 --- a/services/gastown/src/dos/town/beads.ts +++ b/services/gastown/src/dos/town/beads.ts @@ -721,13 +721,13 @@ export function deleteBeads(sql: SqlStorage, beadIds: string[]): number { const allIds = new Set(beadIds); // Expand with child beads (molecule steps, etc.) - const childRows = [ - ...query( - sql, - /* sql */ `SELECT ${beads.bead_id} FROM ${beads} WHERE ${beads.parent_bead_id} IN (${beadIds.map(() => '?').join(',')})`, - [...beadIds] - ), - ]; + // Dynamic IN clauses use sql.exec directly — the type-safe query() + // wrapper can't infer placeholder count from runtime-built strings. + const ph = (ids: string[]) => ids.map(() => '?').join(','); + const childRows = [...sql.exec( + /* sql */ `SELECT ${beads.bead_id} FROM ${beads} WHERE ${beads.parent_bead_id} IN (${ph(beadIds)})`, + ...beadIds + )]; const childIds = BeadRecord.pick({ bead_id: true }) .array() .parse(childRows) @@ -746,61 +746,51 @@ export function deleteBeads(sql: SqlStorage, beadIds: string[]): number { } const allIdsArr = [...allIds]; - const placeholders = allIdsArr.map(() => '?').join(','); + const placeholders = ph(allIdsArr); // Unhook agents assigned to any of these beads - query( - sql, - /* sql */ ` - UPDATE ${agent_metadata} + sql.exec( + /* sql */ `UPDATE ${agent_metadata} SET ${agent_metadata.columns.current_hook_bead_id} = NULL, ${agent_metadata.columns.status} = 'idle' - WHERE ${agent_metadata.current_hook_bead_id} IN (${placeholders}) - `, - [...allIdsArr] + WHERE ${agent_metadata.current_hook_bead_id} IN (${placeholders})`, + ...allIdsArr ); // Delete dependencies referencing any of these beads - query( - sql, + sql.exec( /* sql */ `DELETE FROM ${bead_dependencies} WHERE ${bead_dependencies.bead_id} IN (${placeholders}) OR ${bead_dependencies.depends_on_bead_id} IN (${placeholders})`, - [...allIdsArr, ...allIdsArr] + ...allIdsArr, ...allIdsArr ); // Delete events - query( - sql, + sql.exec( /* sql */ `DELETE FROM ${bead_events} WHERE ${bead_events.bead_id} IN (${placeholders})`, - [...allIdsArr] + ...allIdsArr ); // Delete satellite metadata - query( - sql, + sql.exec( /* sql */ `DELETE FROM ${agent_metadata} WHERE ${agent_metadata.bead_id} IN (${placeholders})`, - [...allIdsArr] + ...allIdsArr ); - query( - sql, + sql.exec( /* sql */ `DELETE FROM ${review_metadata} WHERE ${review_metadata.bead_id} IN (${placeholders})`, - [...allIdsArr] + ...allIdsArr ); - query( - sql, + sql.exec( /* sql */ `DELETE FROM ${escalation_metadata} WHERE ${escalation_metadata.bead_id} IN (${placeholders})`, - [...allIdsArr] + ...allIdsArr ); - query( - sql, + sql.exec( /* sql */ `DELETE FROM ${convoy_metadata} WHERE ${convoy_metadata.bead_id} IN (${placeholders})`, - [...allIdsArr] + ...allIdsArr ); // Delete the beads themselves - query( - sql, + sql.exec( /* sql */ `DELETE FROM ${beads} WHERE ${beads.bead_id} IN (${placeholders})`, - [...allIdsArr] + ...allIdsArr ); return allIdsArr.length; @@ -809,10 +799,9 @@ export function deleteBeads(sql: SqlStorage, beadIds: string[]): number { function collectChildBeadIds(sql: SqlStorage, parentIds: string[]): string[] { if (parentIds.length === 0) return []; const childRows = [ - ...query( - sql, + ...sql.exec( /* sql */ `SELECT ${beads.bead_id} FROM ${beads} WHERE ${beads.parent_bead_id} IN (${parentIds.map(() => '?').join(',')})`, - [...parentIds] + ...parentIds ), ]; const childIds = BeadRecord.pick({ bead_id: true }) @@ -838,10 +827,9 @@ export function deleteBeadsByStatus( } const rows = [ - ...query( - sql, + ...sql.exec( /* sql */ `SELECT ${beads.bead_id} FROM ${beads} WHERE ${conditions.join(' AND ')}`, - values + ...values ), ]; const beadIds = BeadRecord.pick({ bead_id: true }) diff --git a/services/gastown/src/handlers/mayor-tools.handler.ts b/services/gastown/src/handlers/mayor-tools.handler.ts index 852bf96391..ad55ba0200 100644 --- a/services/gastown/src/handlers/mayor-tools.handler.ts +++ b/services/gastown/src/handlers/mayor-tools.handler.ts @@ -648,9 +648,9 @@ export async function handleMayorBulkDeleteBeads( return c.json(resError('Rig not found in this town'), 403); } - const parsed = await parseJsonBody(c, MayorBulkDeleteBeadsBody); + const parsed = MayorBulkDeleteBeadsBody.safeParse(await parseJsonBody(c)); if (!parsed.success) { - return c.json(resError('Invalid request body', parsed.error), 400); + return c.json(resError(`Invalid request body: ${parsed.error.message}`), 400); } const { bead_ids } = parsed.data; @@ -681,9 +681,9 @@ export async function handleMayorDeleteBeadsByStatus( return c.json(resError('Rig not found in this town'), 403); } - const parsed = await parseJsonBody(c, MayorDeleteBeadsByStatusBody); + const parsed = MayorDeleteBeadsByStatusBody.safeParse(await parseJsonBody(c)); if (!parsed.success) { - return c.json(resError('Invalid request body', parsed.error), 400); + return c.json(resError(`Invalid request body: ${parsed.error.message}`), 400); } const { status, type } = parsed.data; diff --git a/services/gastown/src/prompts/polecat-system.prompt.ts b/services/gastown/src/prompts/polecat-system.prompt.ts index e00e4636b3..6e6cba4c35 100644 --- a/services/gastown/src/prompts/polecat-system.prompt.ts +++ b/services/gastown/src/prompts/polecat-system.prompt.ts @@ -88,19 +88,19 @@ When your hooked bead has the \`gt:pr-conflict\` label, **or** when it has the \ 1. Check out the PR branch: \`git fetch origin && git checkout \` 2. Rebase onto the target branch to incorporate its latest changes: - ``` + \`\`\` git rebase origin/ - ``` + \`\`\` 3. If there are conflicts during rebase, resolve them: - - Edit conflicting files to resolve conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`) + - Edit conflicting files to resolve conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`) - Stage the resolved files: \`git add \` - Continue the rebase: \`git rebase --continue\` - Repeat until the rebase completes 4. Push the rebased branch: - ``` + \`\`\` git push --force-with-lease origin - ``` -5. If the bead metadata has `has_feedback: true`, also address the PR review feedback (see PR Fixup Workflow below) before calling gt_done. + \`\`\` +5. If the bead metadata has \`has_feedback: true\`, also address the PR review feedback (see PR Fixup Workflow below) before calling gt_done. 6. Call \`gt_done\` with both required arguments once all conflicts are resolved (and feedback addressed if applicable): - \`pr_url\`: the PR URL from \`pr_conflict_context.pr_url\` - \`branch\`: the branch name from \`pr_conflict_context.branch\` From aa0605b53f75a406efde8d082f7d6f869cf3e000 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Mon, 20 Apr 2026 11:18:43 -0500 Subject: [PATCH 23/33] fix: formatting, lint errors, and bulk delete rig ID mismatch - Run pnpm format across all files flagged by CI - Remove unused ReviewQueueEntry import from Town.do.ts (dead code from popReviewQueue removal) - Fix String(value) lint error in process-manager.ts hot-swap (use typeof check) - Fix bulk delete to use each bead's own rigId from beadRigMap instead of first selected bead's rigId --- .../[townId]/beads/BeadsPageClient.tsx | 14 +++++++++----- .../[townId]/merges/NeedsAttention.tsx | 4 +++- .../settings/TownSettingsPageClient.tsx | 14 +++++++------- .../admin/gastown/towns/[townId]/BeadsTab.tsx | 10 ++++++---- services/gastown/container/plugin/client.ts | 11 ++++------- .../gastown/container/src/process-manager.ts | 8 ++++++-- services/gastown/src/dos/Town.do.ts | 1 - services/gastown/src/dos/town/actions.ts | 3 ++- services/gastown/src/dos/town/beads.ts | 19 +++++++++---------- services/gastown/src/dos/town/reconciler.ts | 4 +++- services/gastown/src/dos/town/review-queue.ts | 5 +---- 11 files changed, 50 insertions(+), 43 deletions(-) diff --git a/apps/web/src/app/(app)/gastown/[townId]/beads/BeadsPageClient.tsx b/apps/web/src/app/(app)/gastown/[townId]/beads/BeadsPageClient.tsx index 0624c0c01e..cdf9808cde 100644 --- a/apps/web/src/app/(app)/gastown/[townId]/beads/BeadsPageClient.tsx +++ b/apps/web/src/app/(app)/gastown/[townId]/beads/BeadsPageClient.tsx @@ -167,8 +167,9 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) { if (!deleteConfirm) return; if (deleteConfirm.kind === 'selected') { - const { ids, rigId } = deleteConfirm; + const { ids } = deleteConfirm; for (const id of ids) { + const rigId = beadRigMap.get(id) ?? ''; deleteBeadMutation.mutate({ rigId, beadId: id, townId }); } } else { @@ -248,9 +249,7 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) { className="overflow-hidden" >
- - {selectedIds.size} selected - + {selectedIds.size} selected
))} - {!isCustom && ( -

- Select “Custom” to change models -

- )} +

+ Select “Custom” to change models +

+ + + ); +} + +function CustomModelPicker({ + customDefault, + setCustomDefault, + customMayor, + setCustomMayor, + customRefinery, + setCustomRefinery, + customPolecat, + setCustomPolecat, + modelOptions, + isLoadingModels, +}: { + customDefault: string; + setCustomDefault: (v: string) => void; + customMayor: string; + setCustomMayor: (v: string) => void; + customRefinery: string; + setCustomRefinery: (v: string) => void; + customPolecat: string; + setCustomPolecat: (v: string) => void; + modelOptions: ModelOption[]; + isLoadingModels: boolean; +}) { + const roleRows: [string, string, (v: string) => void][] = [ + ['Mayor', customMayor, setCustomMayor], + ['Refinery', customRefinery, setCustomRefinery], + ['Polecat', customPolecat, setCustomPolecat], + ]; + + return ( + +
+ {/* Primary default model */} +
+ + +
+ + {/* Per-role overrides — collapsible */} + + + + Override by role (optional) + + + {roleRows.map(([label, value, setValue]) => ( +
+ {label} + + {value && ( + + )} +
+ ))} +
+
+
); @@ -227,6 +314,11 @@ function ModelRolePickers({ export function OnboardingStepModel() { const { state, setModelPreset, setCustomModels } = useOnboarding(); + const [customDefault, setCustomDefault] = useState(state.customModels.defaultModel ?? ''); + const [customMayor, setCustomMayor] = useState(state.customModels.mayor ?? ''); + const [customRefinery, setCustomRefinery] = useState(state.customModels.refinery ?? ''); + const [customPolecat, setCustomPolecat] = useState(state.customModels.polecat ?? ''); + // Fetch available models for the Custom picker (no org context during onboarding) const { data: modelsData, @@ -239,15 +331,8 @@ export function OnboardingStepModel() { [modelsData] ); - // Resolve current models for display: preset values or custom overrides - const currentModels = useMemo(() => { - if (state.modelPreset === 'custom') { - return { - mayor: state.customModels.mayor ?? 'kilo-auto/balanced', - refinery: state.customModels.refinery ?? 'kilo-auto/balanced', - polecat: state.customModels.polecat ?? 'kilo-auto/balanced', - }; - } + // Resolve current models for display in the read-only preset role picker + const currentPresetModels = useMemo(() => { const preset = PRESETS.find(p => p.key === state.modelPreset); if (preset) return preset.models; return { @@ -255,7 +340,7 @@ export function OnboardingStepModel() { refinery: 'kilo-auto/balanced', polecat: 'kilo-auto/balanced', }; - }, [state.modelPreset, state.customModels]); + }, [state.modelPreset]); const isCustom = state.modelPreset === 'custom'; @@ -263,8 +348,25 @@ export function OnboardingStepModel() { setModelPreset(preset); } - function handleCustomUpdate(models: { mayor?: string; refinery?: string; polecat?: string }) { - setCustomModels(models); + // Sync custom model state up to context whenever any field changes + function handleSetCustomDefault(value: string) { + setCustomDefault(value); + setCustomModels({ defaultModel: value, mayor: customMayor, refinery: customRefinery, polecat: customPolecat }); + } + + function handleSetCustomMayor(value: string) { + setCustomMayor(value); + setCustomModels({ defaultModel: customDefault, mayor: value, refinery: customRefinery, polecat: customPolecat }); + } + + function handleSetCustomRefinery(value: string) { + setCustomRefinery(value); + setCustomModels({ defaultModel: customDefault, mayor: customMayor, refinery: value, polecat: customPolecat }); + } + + function handleSetCustomPolecat(value: string) { + setCustomPolecat(value); + setCustomModels({ defaultModel: customDefault, mayor: customMayor, refinery: customRefinery, polecat: value }); } return ( @@ -292,15 +394,31 @@ export function OnboardingStepModel() { handlePresetSelect('custom')} /> - {/* Always-visible model role pickers */} - + {/* Preset model role pickers (read-only) — shown when a preset is active */} + {!isCustom && ( + + )} + + {/* Custom model picker — shown when custom is selected */} + {isCustom && ( + + )} ); diff --git a/apps/web/src/app/(app)/gastown/onboarding/onboarding.domain.ts b/apps/web/src/app/(app)/gastown/onboarding/onboarding.domain.ts index 107214e8c6..2d68b45f9a 100644 --- a/apps/web/src/app/(app)/gastown/onboarding/onboarding.domain.ts +++ b/apps/web/src/app/(app)/gastown/onboarding/onboarding.domain.ts @@ -43,9 +43,11 @@ export function resolveGitUrlFromRepo( export type ModelPreset = 'frontier' | 'balanced' | 'cost-effective' | 'free' | 'custom'; export type CustomModels = { + defaultModel?: string; mayor?: string; refinery?: string; polecat?: string; + smallModel?: string; }; export type PresetConfig = { @@ -110,14 +112,16 @@ export const PRESETS: PresetConfig[] = [ /** Derive the config shape stored in OnboardingState from a preset. */ export function presetToConfig(preset: ModelPreset, customModels: CustomModels) { if (preset === 'custom') { - const mayorModel = customModels.mayor ?? 'kilo-auto/balanced'; + const fallback = 'kilo-auto/balanced'; + const defaultModel = customModels.defaultModel || fallback; return { - default_model: mayorModel, + default_model: defaultModel, role_models: { - mayor: mayorModel, - refinery: customModels.refinery ?? 'kilo-auto/balanced', - polecat: customModels.polecat ?? 'kilo-auto/balanced', + mayor: customModels.mayor || defaultModel, + refinery: customModels.refinery || defaultModel, + polecat: customModels.polecat || defaultModel, }, + small_model: customModels.smallModel || undefined, }; } @@ -133,10 +137,21 @@ export function presetToConfig(preset: ModelPreset, customModels: CustomModels) if (refinery !== mayor) role_models.refinery = refinery; if (polecat !== mayor) role_models.polecat = polecat; - return { + const config: { + default_model: string; + role_models: Record; + small_model?: string; + } = { default_model: mayor, role_models, }; + + // When the free preset is selected, also set small_model to the same free model + if (mayor === 'kilo-auto/free') { + config.small_model = 'kilo-auto/free'; + } + + return config; } // --------------------------------------------------------------------------- diff --git a/apps/web/src/app/(app)/gastown/onboarding/onboarding.test.ts b/apps/web/src/app/(app)/gastown/onboarding/onboarding.test.ts index 6a1a28dc0e..f709654fa3 100644 --- a/apps/web/src/app/(app)/gastown/onboarding/onboarding.test.ts +++ b/apps/web/src/app/(app)/gastown/onboarding/onboarding.test.ts @@ -194,6 +194,7 @@ describe('presetToConfig', () => { test('returns custom config with provided models', () => { const config = presetToConfig('custom', { + defaultModel: 'openai/gpt-4.1', mayor: 'openai/gpt-4.1', refinery: 'anthropic/claude-opus-4', polecat: 'openai/gpt-4.1-mini', @@ -216,9 +217,19 @@ describe('presetToConfig', () => { }); }); - test('uses kilo-auto/balanced for partially-specified custom models', () => { - const config = presetToConfig('custom', { mayor: 'openai/gpt-4.1' }); + test('uses defaultModel as fallback for unset role overrides', () => { + const config = presetToConfig('custom', { defaultModel: 'openai/gpt-4.1', mayor: 'openai/gpt-4.1' }); expect(config.default_model).toBe('openai/gpt-4.1'); + expect(config.role_models).toEqual({ + mayor: 'openai/gpt-4.1', + refinery: 'openai/gpt-4.1', + polecat: 'openai/gpt-4.1', + }); + }); + + test('uses kilo-auto/balanced for default_model when no defaultModel specified', () => { + const config = presetToConfig('custom', { mayor: 'openai/gpt-4.1' }); + expect(config.default_model).toBe('kilo-auto/balanced'); expect(config.role_models).toEqual({ mayor: 'openai/gpt-4.1', refinery: 'kilo-auto/balanced', diff --git a/services/gastown/src/dos/town/pr-feedback.test.ts b/services/gastown/src/dos/town/pr-feedback.test.ts index 4320437188..9f62c30c02 100644 --- a/services/gastown/src/dos/town/pr-feedback.test.ts +++ b/services/gastown/src/dos/town/pr-feedback.test.ts @@ -16,12 +16,12 @@ describe('TownConfigSchema refinery extensions', () => { expect(config.refinery?.code_review).toBe(false); }); - it('defaults auto_resolve_pr_feedback to false', () => { + it('defaults auto_resolve_pr_feedback to true', () => { const config = TownConfigSchema.parse({}); - expect(config.refinery).toBeUndefined(); + expect(config.refinery?.auto_resolve_pr_feedback).toBe(true); const configWithRefinery = TownConfigSchema.parse({ refinery: {} }); - expect(configWithRefinery.refinery?.auto_resolve_pr_feedback).toBe(false); + expect(configWithRefinery.refinery?.auto_resolve_pr_feedback).toBe(true); }); it('defaults auto_merge_delay_minutes to null', () => { diff --git a/services/gastown/src/types.ts b/services/gastown/src/types.ts index 73b7bf604b..60c1ecf67e 100644 --- a/services/gastown/src/types.ts +++ b/services/gastown/src/types.ts @@ -264,7 +264,7 @@ export const TownConfigSchema = z.object({ * - 'direct': Refinery pushes directly to main (no PR) * - 'pr': Refinery creates a GitHub PR / GitLab MR for human review */ - merge_strategy: MergeStrategy.default('direct'), + merge_strategy: MergeStrategy.default('pr'), /** Refinery configuration */ refinery: z @@ -279,19 +279,19 @@ export const TownConfigSchema = z.object({ /** Controls how the refinery communicates review findings: * - 'rework': creates internal rework beads via gt_request_changes (default) * - 'comments': posts GitHub review comments on the PR (requires merge_strategy: 'pr') */ - review_mode: z.enum(['rework', 'comments']).default('rework'), + review_mode: z.enum(['rework', 'comments']).default('comments'), /** When enabled, a polecat is automatically dispatched to address * unresolved review comments and failing CI checks on open PRs. */ - auto_resolve_pr_feedback: z.boolean().default(false), + auto_resolve_pr_feedback: z.boolean().default(true), /** When enabled, a polecat is automatically dispatched to rebase and * resolve merge conflicts on open PRs. */ auto_resolve_merge_conflicts: z.boolean().default(true).optional(), /** After all CI checks pass and all review threads are resolved, * automatically merge the PR after this many minutes. * 0 = immediate, null = disabled (require manual merge). */ - auto_merge_delay_minutes: z.number().int().min(0).nullable().default(null), + auto_merge_delay_minutes: z.number().int().min(0).nullable().default(5), }) - .optional(), + .default({}), /** Alarm interval when agents are active (seconds) */ alarm_interval_active: z.number().int().min(5).max(600).optional(), @@ -307,7 +307,7 @@ export const TownConfigSchema = z.object({ .optional(), /** When true, all convoys are created as staged by default (agents not dispatched until started). */ - staged_convoys_default: z.boolean().default(false), + staged_convoys_default: z.boolean().default(true), /** Default merge mode for new convoys. * - 'review-then-land': beads merge into a convoy feature branch, then a single landing PR is created (default) From d0bee90d9ea325424e6973ee661bfb3564666734 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 22 Apr 2026 22:15:44 -0500 Subject: [PATCH 30/33] feat(gastown): measure true container cold-start and mayor-ready latency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior container-startup panels measured DO→container RPC round-trips (/health and /agents/start), not actual cold-start time — /health is truncated at a 5s client timeout so p99 was bounded below the true cold-start budget, and the dashboard queries filtered on blob8/blob9 values that don't exist in the AE schema so the panels showed nothing. This replaces those with two metrics that answer the original questions: 1. container.cold_start — TownContainerDO.warmUp() invokes the Container class's startAndWaitForPorts() directly and times it. Emitted only when the container was actually started (state != healthy), so the quantiles reflect real cold starts without being capped by an arbitrary client-side timeout. 2. mayor.session_ready — container stamps mayorReadyAt when the first mayor agent transitions to 'running' and exposes it via /health. Town DO reads it and emits durationMs = mayorReadyAt - startedAt exactly once per container lifetime (deduped in DO storage keyed by containerStartedAt). Dashboard fixes: - Rename 'Container Startup Latency' row to 'DO → Container RPC Latency' and clarify panel titles so operators don't read p99 off those and think it's cold-start time. - Fix broken success/failure filters: blob8='ok' / blob9='true' → blob5='' (error absent), blob5!='' (error present). - Convert quantile queries from label-column style (which collapsed all three series to 'latency_ms') to column-name style (AS p50/p90/ p99), so the legend actually distinguishes the percentiles. - Add new 'Container Cold Start & Mayor Ready' row with p50/p90/p99 panels for the two new events. --- .../gastown/container/src/control-server.ts | 2 + .../gastown/container/src/process-manager.ts | 23 ++ services/gastown/container/src/types.ts | 4 + services/gastown/gastown-grafana-dash-1.json | 381 ++++++++++++++++-- services/gastown/src/dos/Town.do.ts | 49 ++- services/gastown/src/dos/TownContainer.do.ts | 21 + 6 files changed, 448 insertions(+), 32 deletions(-) diff --git a/services/gastown/container/src/control-server.ts b/services/gastown/container/src/control-server.ts index 737c8ff939..1e0541346f 100644 --- a/services/gastown/container/src/control-server.ts +++ b/services/gastown/container/src/control-server.ts @@ -10,6 +10,7 @@ import { activeServerCount, getUptime, getStartTime, + getMayorReadyAt, stopAll, drainAll, isDraining, @@ -221,6 +222,7 @@ app.get('/health', c => { uptime: getUptime(), draining: isDraining() || undefined, startedAt: getStartTime(), + mayorReadyAt: getMayorReadyAt() ?? undefined, }; return c.json(response); }); diff --git a/services/gastown/container/src/process-manager.ts b/services/gastown/container/src/process-manager.ts index f2dea4be01..c025899e4e 100644 --- a/services/gastown/container/src/process-manager.ts +++ b/services/gastown/container/src/process-manager.ts @@ -78,6 +78,26 @@ export function getStartTime(): string { return new Date(startTime).toISOString(); } +// Timestamp (ISO 8601) of the moment the first mayor agent in this container +// reached 'running' status. Used by /health so the Town DO can compute +// container-start-to-mayor-ready latency. Stays null until a mayor is up; +// survives subsequent mayor exits since the window is measured against the +// first mayor ready in the container's lifetime. +let mayorReadyAt: string | null = null; + +export function getMayorReadyAt(): string | null { + return mayorReadyAt; +} + +function markMayorReadyOnce(): void { + if (mayorReadyAt !== null) return; + mayorReadyAt = new Date().toISOString(); + log.info('mayor.ready', { + containerUptimeMs: getUptime(), + mayorReadyAt, + }); +} + async function hydrateDbFromSnapshot( agentId: string, apiUrl: string, @@ -1044,6 +1064,9 @@ export async function startAgent( // despite being active — causing the drain to wait indefinitely. if (agent.status === 'starting') { agent.status = 'running'; + if (request.role === 'mayor') { + markMayorReadyOnce(); + } } // 4. Send the initial prompt diff --git a/services/gastown/container/src/types.ts b/services/gastown/container/src/types.ts index d09a1e53c6..490cd9c1df 100644 --- a/services/gastown/container/src/types.ts +++ b/services/gastown/container/src/types.ts @@ -165,6 +165,10 @@ export type HealthResponse = { uptime: number; draining?: boolean; startedAt?: string; + /** ISO 8601 timestamp of the first mayor agent reaching 'running' status + * in this container's lifetime. Used by the worker to measure container + * cold-start → mayor-session-ready latency. */ + mayorReadyAt?: string; }; // ── Kilo serve instance ───────────────────────────────────────────────── diff --git a/services/gastown/gastown-grafana-dash-1.json b/services/gastown/gastown-grafana-dash-1.json index ee44a3ec0b..f050a1c1e5 100644 --- a/services/gastown/gastown-grafana-dash-1.json +++ b/services/gastown/gastown-grafana-dash-1.json @@ -3184,7 +3184,7 @@ }, "id": 300, "panels": [], - "title": "Container Startup Latency", + "title": "DO → Container RPC Latency", "type": "row" }, { @@ -3256,8 +3256,8 @@ "interval": "", "intervalFactor": 1, "nullifySparse": false, - "query": "SELECT SUM(_sample_interval * double1) / SUM(_sample_interval) AS avg_ms FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' AND blob8 = 'ok'", - "rawSql": "SELECT SUM(_sample_interval * double1) / SUM(_sample_interval) AS avg_ms FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' AND blob8 = 'ok'", + "query": "SELECT SUM(_sample_interval * double1) / SUM(_sample_interval) AS avg_ms FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' AND blob5 = ''", + "rawSql": "SELECT SUM(_sample_interval * double1) / SUM(_sample_interval) AS avg_ms FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' AND blob5 = ''", "refId": "A", "round": "0s", "showFormattedSQL": false, @@ -3268,7 +3268,7 @@ } ], "timeFrom": "1h", - "title": "Avg Health Ping (1h)", + "title": "Avg /health RPC (1h)", "type": "stat" }, { @@ -3340,8 +3340,8 @@ "interval": "", "intervalFactor": 1, "nullifySparse": false, - "query": "SELECT SUM(_sample_interval * double1) / SUM(_sample_interval) AS avg_ms FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' AND blob9 = 'true'", - "rawSql": "SELECT SUM(_sample_interval * double1) / SUM(_sample_interval) AS avg_ms FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' AND blob9 = 'true'", + "query": "SELECT SUM(_sample_interval * double1) / SUM(_sample_interval) AS avg_ms FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' AND blob5 = ''", + "rawSql": "SELECT SUM(_sample_interval * double1) / SUM(_sample_interval) AS avg_ms FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' AND blob5 = ''", "refId": "A", "round": "0s", "showFormattedSQL": false, @@ -3352,7 +3352,7 @@ } ], "timeFrom": "1h", - "title": "Avg Agent Start Fetch (1h)", + "title": "Avg /agents/start RPC (1h)", "type": "stat" }, { @@ -3473,8 +3473,8 @@ "interval": "", "intervalFactor": 1, "nullifySparse": false, - "query": "SELECT $timeSeries AS t, 'p50' AS label, quantileWeighted(0.50)(double1, _sample_interval) AS latency_ms FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' AND blob8 = 'ok' GROUP BY t ORDER BY t", - "rawSql": "SELECT $timeSeries AS t, 'p50' AS label, quantileWeighted(0.50)(double1, _sample_interval) AS latency_ms FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' AND blob8 = 'ok' GROUP BY t ORDER BY t", + "query": "SELECT $timeSeries AS t, quantileWeighted(0.50)(double1, _sample_interval) AS p50 FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' AND blob5 = '' GROUP BY t ORDER BY t", + "rawSql": "SELECT $timeSeries AS t, quantileWeighted(0.50)(double1, _sample_interval) AS p50 FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' AND blob5 = '' GROUP BY t ORDER BY t", "refId": "A", "round": "0s", "showFormattedSQL": false, @@ -3496,8 +3496,8 @@ "interval": "", "intervalFactor": 1, "nullifySparse": false, - "query": "SELECT $timeSeries AS t, 'p90' AS label, quantileWeighted(0.90)(double1, _sample_interval) AS latency_ms FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' AND blob8 = 'ok' GROUP BY t ORDER BY t", - "rawSql": "SELECT $timeSeries AS t, 'p90' AS label, quantileWeighted(0.90)(double1, _sample_interval) AS latency_ms FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' AND blob8 = 'ok' GROUP BY t ORDER BY t", + "query": "SELECT $timeSeries AS t, quantileWeighted(0.90)(double1, _sample_interval) AS p90 FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' AND blob5 = '' GROUP BY t ORDER BY t", + "rawSql": "SELECT $timeSeries AS t, quantileWeighted(0.90)(double1, _sample_interval) AS p90 FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' AND blob5 = '' GROUP BY t ORDER BY t", "refId": "B", "round": "0s", "showFormattedSQL": false, @@ -3519,8 +3519,8 @@ "interval": "", "intervalFactor": 1, "nullifySparse": false, - "query": "SELECT $timeSeries AS t, 'p99' AS label, quantileWeighted(0.99)(double1, _sample_interval) AS latency_ms FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' AND blob8 = 'ok' GROUP BY t ORDER BY t", - "rawSql": "SELECT $timeSeries AS t, 'p99' AS label, quantileWeighted(0.99)(double1, _sample_interval) AS latency_ms FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' AND blob8 = 'ok' GROUP BY t ORDER BY t", + "query": "SELECT $timeSeries AS t, quantileWeighted(0.99)(double1, _sample_interval) AS p99 FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' AND blob5 = '' GROUP BY t ORDER BY t", + "rawSql": "SELECT $timeSeries AS t, quantileWeighted(0.99)(double1, _sample_interval) AS p99 FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' AND blob5 = '' GROUP BY t ORDER BY t", "refId": "C", "round": "0s", "showFormattedSQL": false, @@ -3542,8 +3542,8 @@ "interval": "", "intervalFactor": 1, "nullifySparse": false, - "query": "SELECT $timeSeries AS t, 'timeout_rate' AS label, SUM(IF(blob8 = 'timeout', _sample_interval, 0)) / SUM(_sample_interval) AS timeout_rate FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' GROUP BY t ORDER BY t", - "rawSql": "SELECT $timeSeries AS t, 'timeout_rate' AS label, SUM(IF(blob8 = 'timeout', _sample_interval, 0)) / SUM(_sample_interval) AS timeout_rate FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' GROUP BY t ORDER BY t", + "query": "SELECT $timeSeries AS t, 'timeout_rate' AS label, SUM(IF(blob5 != '', _sample_interval, 0)) / SUM(_sample_interval) AS timeout_rate FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' GROUP BY t ORDER BY t", + "rawSql": "SELECT $timeSeries AS t, 'timeout_rate' AS label, SUM(IF(blob5 != '', _sample_interval, 0)) / SUM(_sample_interval) AS timeout_rate FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' GROUP BY t ORDER BY t", "refId": "D", "round": "0s", "showFormattedSQL": false, @@ -3553,7 +3553,7 @@ "useWindowFuncForMacros": true } ], - "title": "Container Health Ping Latency (cold-start indicator)", + "title": "/health RPC Latency (DO → container round-trip)", "type": "timeseries" }, { @@ -3674,8 +3674,8 @@ "interval": "", "intervalFactor": 1, "nullifySparse": false, - "query": "SELECT $timeSeries AS t, 'p50' AS label, quantileWeighted(0.50)(double1, _sample_interval) AS latency_ms FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' AND blob9 = 'true' GROUP BY t ORDER BY t", - "rawSql": "SELECT $timeSeries AS t, 'p50' AS label, quantileWeighted(0.50)(double1, _sample_interval) AS latency_ms FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' AND blob9 = 'true' GROUP BY t ORDER BY t", + "query": "SELECT $timeSeries AS t, quantileWeighted(0.50)(double1, _sample_interval) AS p50 FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' AND blob5 = '' GROUP BY t ORDER BY t", + "rawSql": "SELECT $timeSeries AS t, quantileWeighted(0.50)(double1, _sample_interval) AS p50 FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' AND blob5 = '' GROUP BY t ORDER BY t", "refId": "A", "round": "0s", "showFormattedSQL": false, @@ -3697,8 +3697,8 @@ "interval": "", "intervalFactor": 1, "nullifySparse": false, - "query": "SELECT $timeSeries AS t, 'p90' AS label, quantileWeighted(0.90)(double1, _sample_interval) AS latency_ms FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' AND blob9 = 'true' GROUP BY t ORDER BY t", - "rawSql": "SELECT $timeSeries AS t, 'p90' AS label, quantileWeighted(0.90)(double1, _sample_interval) AS latency_ms FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' AND blob9 = 'true' GROUP BY t ORDER BY t", + "query": "SELECT $timeSeries AS t, quantileWeighted(0.90)(double1, _sample_interval) AS p90 FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' AND blob5 = '' GROUP BY t ORDER BY t", + "rawSql": "SELECT $timeSeries AS t, quantileWeighted(0.90)(double1, _sample_interval) AS p90 FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' AND blob5 = '' GROUP BY t ORDER BY t", "refId": "B", "round": "0s", "showFormattedSQL": false, @@ -3720,8 +3720,8 @@ "interval": "", "intervalFactor": 1, "nullifySparse": false, - "query": "SELECT $timeSeries AS t, 'p99' AS label, quantileWeighted(0.99)(double1, _sample_interval) AS latency_ms FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' AND blob9 = 'true' GROUP BY t ORDER BY t", - "rawSql": "SELECT $timeSeries AS t, 'p99' AS label, quantileWeighted(0.99)(double1, _sample_interval) AS latency_ms FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' AND blob9 = 'true' GROUP BY t ORDER BY t", + "query": "SELECT $timeSeries AS t, quantileWeighted(0.99)(double1, _sample_interval) AS p99 FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' AND blob5 = '' GROUP BY t ORDER BY t", + "rawSql": "SELECT $timeSeries AS t, quantileWeighted(0.99)(double1, _sample_interval) AS p99 FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' AND blob5 = '' GROUP BY t ORDER BY t", "refId": "C", "round": "0s", "showFormattedSQL": false, @@ -3743,8 +3743,8 @@ "interval": "", "intervalFactor": 1, "nullifySparse": false, - "query": "SELECT $timeSeries AS t, 'failure_rate' AS label, SUM(IF(blob9 = 'false', _sample_interval, 0)) / SUM(_sample_interval) AS failure_rate FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' GROUP BY t ORDER BY t", - "rawSql": "SELECT $timeSeries AS t, 'failure_rate' AS label, SUM(IF(blob9 = 'false', _sample_interval, 0)) / SUM(_sample_interval) AS failure_rate FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' GROUP BY t ORDER BY t", + "query": "SELECT $timeSeries AS t, 'failure_rate' AS label, SUM(IF(blob5 != '', _sample_interval, 0)) / SUM(_sample_interval) AS failure_rate FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' GROUP BY t ORDER BY t", + "rawSql": "SELECT $timeSeries AS t, 'failure_rate' AS label, SUM(IF(blob5 != '', _sample_interval, 0)) / SUM(_sample_interval) AS failure_rate FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' GROUP BY t ORDER BY t", "refId": "D", "round": "0s", "showFormattedSQL": false, @@ -3754,7 +3754,7 @@ "useWindowFuncForMacros": true } ], - "title": "Container Agent Start Latency (Town DO \u2192 container.fetch('/agents/start') round-trip)", + "title": "Container Agent Start Latency (Town DO → container.fetch('/agents/start') round-trip)", "type": "timeseries" }, { @@ -3850,8 +3850,8 @@ "interval": "", "intervalFactor": 1, "nullifySparse": false, - "query": "SELECT $timeSeries AS t, 'timeout_rate' AS label, SUM(IF(blob8 = 'timeout', _sample_interval, 0)) / SUM(_sample_interval) AS rate FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' GROUP BY t ORDER BY t", - "rawSql": "SELECT $timeSeries AS t, 'timeout_rate' AS label, SUM(IF(blob8 = 'timeout', _sample_interval, 0)) / SUM(_sample_interval) AS rate FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' GROUP BY t ORDER BY t", + "query": "SELECT $timeSeries AS t, 'timeout_rate' AS label, SUM(IF(blob5 != '', _sample_interval, 0)) / SUM(_sample_interval) AS rate FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' GROUP BY t ORDER BY t", + "rawSql": "SELECT $timeSeries AS t, 'timeout_rate' AS label, SUM(IF(blob5 != '', _sample_interval, 0)) / SUM(_sample_interval) AS rate FROM gastown_events WHERE $timeFilter AND blob1 = 'container.health_ping' GROUP BY t ORDER BY t", "refId": "A", "round": "0s", "showFormattedSQL": false, @@ -3861,7 +3861,7 @@ "useWindowFuncForMacros": true } ], - "title": "Health Ping Timeout Rate (container cold-start frequency)", + "title": "/health RPC Failure Rate", "type": "timeseries" }, { @@ -3987,8 +3987,8 @@ "interval": "", "intervalFactor": 1, "nullifySparse": false, - "query": "SELECT $timeSeries AS t, IF(blob9 = 'true', 'success', 'failure') AS label, SUM(_sample_interval) AS count FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' GROUP BY t, label ORDER BY t", - "rawSql": "SELECT $timeSeries AS t, IF(blob9 = 'true', 'success', 'failure') AS label, SUM(_sample_interval) AS count FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' GROUP BY t, label ORDER BY t", + "query": "SELECT $timeSeries AS t, IF(blob5 = '', 'success', 'failure') AS label, SUM(_sample_interval) AS count FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' GROUP BY t, label ORDER BY t", + "rawSql": "SELECT $timeSeries AS t, IF(blob5 = '', 'success', 'failure') AS label, SUM(_sample_interval) AS count FROM gastown_events WHERE $timeFilter AND blob1 = 'container.agent_start_fetch' GROUP BY t, label ORDER BY t", "refId": "A", "round": "0s", "showFormattedSQL": false, @@ -3998,7 +3998,326 @@ "useWindowFuncForMacros": true } ], - "title": "Agent Start Attempts (success / failure)", + "title": "/agents/start Attempts (success / failure)", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 150 + }, + "id": 400, + "panels": [], + "title": "Container Cold Start & Mayor Ready", + "type": "row" + }, + { + "datasource": { + "type": "vertamedia-clickhouse-datasource", + "uid": "bffxugc31cnpcc" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 15, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 151 + }, + "id": 401, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.1", + "targets": [ + { + "adHocFilters": [], + "adHocValuesQuery": "", + "add_metadata": true, + "contextWindowSize": "10", + "dateTimeColDataType": "timestamp", + "dateTimeType": "DATETIME", + "editorMode": "sql", + "extrapolate": true, + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "nullifySparse": false, + "query": "SELECT $timeSeries AS t, quantileWeighted(0.50)(double1, _sample_interval) AS p50 FROM gastown_events WHERE $timeFilter AND blob1 = 'container.cold_start' AND blob5 = '' GROUP BY t ORDER BY t", + "rawSql": "SELECT $timeSeries AS t, quantileWeighted(0.50)(double1, _sample_interval) AS p50 FROM gastown_events WHERE $timeFilter AND blob1 = 'container.cold_start' AND blob5 = '' GROUP BY t ORDER BY t", + "refId": "A", + "round": "0s", + "showFormattedSQL": false, + "showHelp": false, + "skip_comments": true, + "table": "gastown_events", + "useWindowFuncForMacros": true + }, + { + "adHocFilters": [], + "adHocValuesQuery": "", + "add_metadata": true, + "contextWindowSize": "10", + "dateTimeColDataType": "timestamp", + "dateTimeType": "DATETIME", + "editorMode": "sql", + "extrapolate": true, + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "nullifySparse": false, + "query": "SELECT $timeSeries AS t, quantileWeighted(0.90)(double1, _sample_interval) AS p90 FROM gastown_events WHERE $timeFilter AND blob1 = 'container.cold_start' AND blob5 = '' GROUP BY t ORDER BY t", + "rawSql": "SELECT $timeSeries AS t, quantileWeighted(0.90)(double1, _sample_interval) AS p90 FROM gastown_events WHERE $timeFilter AND blob1 = 'container.cold_start' AND blob5 = '' GROUP BY t ORDER BY t", + "refId": "B", + "round": "0s", + "showFormattedSQL": false, + "showHelp": false, + "skip_comments": true, + "table": "gastown_events", + "useWindowFuncForMacros": true + }, + { + "adHocFilters": [], + "adHocValuesQuery": "", + "add_metadata": true, + "contextWindowSize": "10", + "dateTimeColDataType": "timestamp", + "dateTimeType": "DATETIME", + "editorMode": "sql", + "extrapolate": true, + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "nullifySparse": false, + "query": "SELECT $timeSeries AS t, quantileWeighted(0.99)(double1, _sample_interval) AS p99 FROM gastown_events WHERE $timeFilter AND blob1 = 'container.cold_start' AND blob5 = '' GROUP BY t ORDER BY t", + "rawSql": "SELECT $timeSeries AS t, quantileWeighted(0.99)(double1, _sample_interval) AS p99 FROM gastown_events WHERE $timeFilter AND blob1 = 'container.cold_start' AND blob5 = '' GROUP BY t ORDER BY t", + "refId": "C", + "round": "0s", + "showFormattedSQL": false, + "showHelp": false, + "skip_comments": true, + "table": "gastown_events", + "useWindowFuncForMacros": true + } + ], + "title": "Container Cold Start Latency (startAndWaitForPorts, p50/p90/p99)", + "type": "timeseries" + }, + { + "datasource": { + "type": "vertamedia-clickhouse-datasource", + "uid": "bffxugc31cnpcc" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 15, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 151 + }, + "id": 402, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.1", + "targets": [ + { + "adHocFilters": [], + "adHocValuesQuery": "", + "add_metadata": true, + "contextWindowSize": "10", + "dateTimeColDataType": "timestamp", + "dateTimeType": "DATETIME", + "editorMode": "sql", + "extrapolate": true, + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "nullifySparse": false, + "query": "SELECT $timeSeries AS t, quantileWeighted(0.50)(double1, _sample_interval) AS p50 FROM gastown_events WHERE $timeFilter AND blob1 = 'mayor.session_ready' AND blob5 = '' GROUP BY t ORDER BY t", + "rawSql": "SELECT $timeSeries AS t, quantileWeighted(0.50)(double1, _sample_interval) AS p50 FROM gastown_events WHERE $timeFilter AND blob1 = 'mayor.session_ready' AND blob5 = '' GROUP BY t ORDER BY t", + "refId": "A", + "round": "0s", + "showFormattedSQL": false, + "showHelp": false, + "skip_comments": true, + "table": "gastown_events", + "useWindowFuncForMacros": true + }, + { + "adHocFilters": [], + "adHocValuesQuery": "", + "add_metadata": true, + "contextWindowSize": "10", + "dateTimeColDataType": "timestamp", + "dateTimeType": "DATETIME", + "editorMode": "sql", + "extrapolate": true, + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "nullifySparse": false, + "query": "SELECT $timeSeries AS t, quantileWeighted(0.90)(double1, _sample_interval) AS p90 FROM gastown_events WHERE $timeFilter AND blob1 = 'mayor.session_ready' AND blob5 = '' GROUP BY t ORDER BY t", + "rawSql": "SELECT $timeSeries AS t, quantileWeighted(0.90)(double1, _sample_interval) AS p90 FROM gastown_events WHERE $timeFilter AND blob1 = 'mayor.session_ready' AND blob5 = '' GROUP BY t ORDER BY t", + "refId": "B", + "round": "0s", + "showFormattedSQL": false, + "showHelp": false, + "skip_comments": true, + "table": "gastown_events", + "useWindowFuncForMacros": true + }, + { + "adHocFilters": [], + "adHocValuesQuery": "", + "add_metadata": true, + "contextWindowSize": "10", + "dateTimeColDataType": "timestamp", + "dateTimeType": "DATETIME", + "editorMode": "sql", + "extrapolate": true, + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "nullifySparse": false, + "query": "SELECT $timeSeries AS t, quantileWeighted(0.99)(double1, _sample_interval) AS p99 FROM gastown_events WHERE $timeFilter AND blob1 = 'mayor.session_ready' AND blob5 = '' GROUP BY t ORDER BY t", + "rawSql": "SELECT $timeSeries AS t, quantileWeighted(0.99)(double1, _sample_interval) AS p99 FROM gastown_events WHERE $timeFilter AND blob1 = 'mayor.session_ready' AND blob5 = '' GROUP BY t ORDER BY t", + "refId": "C", + "round": "0s", + "showFormattedSQL": false, + "showHelp": false, + "skip_comments": true, + "table": "gastown_events", + "useWindowFuncForMacros": true + } + ], + "title": "Mayor Session Ready (container start → mayor running, p50/p90/p99)", "type": "timeseries" } ], diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index ccfdf5e00c..2d7c94144a 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -4423,6 +4423,30 @@ export class TownDO extends DurableObject { try { const container = getTownContainerStub(this.env, townId); + + // Measure Cloudflare container cold-start latency from the worker's + // perspective: warmUp() invokes startAndWaitForPorts() directly, so the + // returned durationMs is the true time-to-ready without the arbitrary + // 5s truncation of a plain /health ping. For already-warm containers + // this is a cheap RPC that returns { coldStart: false }. + try { + const warm = await container.warmUp(); + if (warm.coldStart) { + writeEvent(this.env, { + event: 'container.cold_start', + townId, + durationMs: warm.durationMs, + }); + } + } catch (err) { + writeEvent(this.env, { + event: 'container.cold_start', + townId, + error: err instanceof Error ? err.message.slice(0, 300) : String(err).slice(0, 300), + }); + // Fall through to /health ping anyway — the container may recover. + } + // Always include X-Town-Config so the container populates // lastKnownTownConfig on startup — before any /agents/start arrives. // This ensures org context and credentials are available immediately @@ -4473,7 +4497,11 @@ export class TownDO extends DurableObject { }); const rawBody: unknown = await healthResp.json().catch(() => null); const HealthBody = z - .object({ startedAt: z.string().optional(), uptime: z.number().optional() }) + .object({ + startedAt: z.string().optional(), + uptime: z.number().optional(), + mayorReadyAt: z.string().optional(), + }) .passthrough(); const body = HealthBody.safeParse(rawBody); if (body.success && body.data.startedAt) { @@ -4484,6 +4512,25 @@ export class TownDO extends DurableObject { containerStartedAt: body.data.startedAt, durationMs: Date.now() - containerStartedAt, }); + + // Emit mayor.session_ready exactly once per container instance. + // Keyed by the container's startedAt so a restart re-arms the + // measurement. Stored durably so restarts of this DO don't cause + // duplicate emissions. + if (body.data.mayorReadyAt) { + const key = `mayor:ready_reported_for:${body.data.startedAt}`; + const alreadyReported = await this.ctx.storage.get(key); + if (!alreadyReported) { + await this.ctx.storage.put(key, true); + const mayorReadyAt = new Date(body.data.mayorReadyAt).getTime(); + writeEvent(this.env, { + event: 'mayor.session_ready', + townId, + containerStartedAt: body.data.startedAt, + durationMs: mayorReadyAt - containerStartedAt, + }); + } + } } } } catch { diff --git a/services/gastown/src/dos/TownContainer.do.ts b/services/gastown/src/dos/TownContainer.do.ts index 06a34fed03..31fe14803d 100644 --- a/services/gastown/src/dos/TownContainer.do.ts +++ b/services/gastown/src/dos/TownContainer.do.ts @@ -83,6 +83,27 @@ export class TownContainerDO extends Container { console.log(`${TC_LOG} container started for DO id=${this.ctx.id.toString()}`); } + /** + * Ensure the container is running and its default port is ready to accept + * traffic. Returns how long the underlying Container class took to satisfy + * that (i.e. `startAndWaitForPorts`), along with whether this call actually + * triggered a cold start. + * + * Intended to be called from the Town DO alarm in place of a manual + * /health ping — gives an accurate cold-start measurement without being + * capped by an arbitrary client-side timeout. + */ + async warmUp(): Promise<{ coldStart: boolean; durationMs: number }> { + const state = await this.getState(); + const alreadyHealthy = this.ctx.container?.running === true && state.status === 'healthy'; + if (alreadyHealthy) { + return { coldStart: false, durationMs: 0 }; + } + const t0 = Date.now(); + await this.startAndWaitForPorts(); + return { coldStart: true, durationMs: Date.now() - t0 }; + } + override onStop({ exitCode, reason }: { exitCode: number; reason: string }): void { console.log( `${TC_LOG} container stopped: exitCode=${exitCode} reason=${reason} id=${this.ctx.id.toString()}` From 00905b429692a5218dddb7838ffc2a706b67c1ab Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 22 Apr 2026 22:35:58 -0500 Subject: [PATCH 31/33] chore: resolve stale rebase conflicts and fix types.ts overload error - Resolve unresolved merge-conflict markers left in the onboarding UI files from an earlier aborted rebase; take the version from #2725 (merged) in all three cases, since that matches the now-live UI. - Remove three stale src/app/... files that were moved to apps/web/... upstream but lingered as "deleted by us" conflicts. - Fix tsgo overload error in services/gastown/src/types.ts: the .default({}) on the refinery sub-schema wasn't satisfied by the empty-object literal because the inner shape has no required optional fields from the compiler's perspective. Spell out the defaults explicitly. --- .../onboarding/OnboardingStepModel.tsx | 28 ++++++++++++++++--- .../gastown/onboarding/onboarding.test.ts | 5 +++- services/gastown/src/types.ts | 10 ++++++- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx b/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx index 59359c5fbd..d77d457ab6 100644 --- a/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx +++ b/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx @@ -351,22 +351,42 @@ export function OnboardingStepModel() { // Sync custom model state up to context whenever any field changes function handleSetCustomDefault(value: string) { setCustomDefault(value); - setCustomModels({ defaultModel: value, mayor: customMayor, refinery: customRefinery, polecat: customPolecat }); + setCustomModels({ + defaultModel: value, + mayor: customMayor, + refinery: customRefinery, + polecat: customPolecat, + }); } function handleSetCustomMayor(value: string) { setCustomMayor(value); - setCustomModels({ defaultModel: customDefault, mayor: value, refinery: customRefinery, polecat: customPolecat }); + setCustomModels({ + defaultModel: customDefault, + mayor: value, + refinery: customRefinery, + polecat: customPolecat, + }); } function handleSetCustomRefinery(value: string) { setCustomRefinery(value); - setCustomModels({ defaultModel: customDefault, mayor: customMayor, refinery: value, polecat: customPolecat }); + setCustomModels({ + defaultModel: customDefault, + mayor: customMayor, + refinery: value, + polecat: customPolecat, + }); } function handleSetCustomPolecat(value: string) { setCustomPolecat(value); - setCustomModels({ defaultModel: customDefault, mayor: customMayor, refinery: customRefinery, polecat: value }); + setCustomModels({ + defaultModel: customDefault, + mayor: customMayor, + refinery: customRefinery, + polecat: value, + }); } return ( diff --git a/apps/web/src/app/(app)/gastown/onboarding/onboarding.test.ts b/apps/web/src/app/(app)/gastown/onboarding/onboarding.test.ts index f709654fa3..cea8a3a4f8 100644 --- a/apps/web/src/app/(app)/gastown/onboarding/onboarding.test.ts +++ b/apps/web/src/app/(app)/gastown/onboarding/onboarding.test.ts @@ -218,7 +218,10 @@ describe('presetToConfig', () => { }); test('uses defaultModel as fallback for unset role overrides', () => { - const config = presetToConfig('custom', { defaultModel: 'openai/gpt-4.1', mayor: 'openai/gpt-4.1' }); + const config = presetToConfig('custom', { + defaultModel: 'openai/gpt-4.1', + mayor: 'openai/gpt-4.1', + }); expect(config.default_model).toBe('openai/gpt-4.1'); expect(config.role_models).toEqual({ mayor: 'openai/gpt-4.1', diff --git a/services/gastown/src/types.ts b/services/gastown/src/types.ts index 60c1ecf67e..3707bb3801 100644 --- a/services/gastown/src/types.ts +++ b/services/gastown/src/types.ts @@ -291,7 +291,15 @@ export const TownConfigSchema = z.object({ * 0 = immediate, null = disabled (require manual merge). */ auto_merge_delay_minutes: z.number().int().min(0).nullable().default(5), }) - .default({}), + .default({ + gates: [], + auto_merge: true, + require_clean_merge: true, + code_review: true, + review_mode: 'comments', + auto_resolve_pr_feedback: true, + auto_merge_delay_minutes: 5, + }), /** Alarm interval when agents are active (seconds) */ alarm_interval_active: z.number().int().min(5).max(600).optional(), From 7544e67fe00d5511ce37adb272a857d9d9f1719a Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 22 Apr 2026 22:44:47 -0500 Subject: [PATCH 32/33] fix(gastown): don't retroactively apply #2725 town-config defaults to legacy towns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses kilo-code-bot's critical review on services/gastown/src/types.ts: TownConfigSchema is the parse schema for PERSISTED town config (loaded via getTownConfig on every access), so the default-value changes in #2725 were silently flipping existing-town behavior on every load. A town created before #2725 with no merge_strategy saved would start reporting merge_strategy='pr' / staged_convoys_default=true / refinery.auto_merge_ delay_minutes=5 / etc. the moment the new code deployed — retroactively enabling PR auto-merge on rigs the owner never opted in to. Fix: - Revert TownConfigSchema defaults to their pre-#2725 values so existing persisted configs keep their historical behavior on reload. - Move the new-style defaults into a single NEW_TOWN_CONFIG_DEFAULTS constant that getTownConfig() seeds exactly once, when a Town DO loads its config and finds nothing persisted (i.e. the town is fresh). The seeded values are written back to storage, so subsequent reads never rely on schema defaults. - Update pr-feedback.test.ts: the test that asserted parsing {} yielded auto_resolve_pr_feedback=true encoded the exact hazard we're fixing. Rewrite it to assert the conservative schema behavior (undefined / false / null) and add a new config.test.ts pair covering (a) fresh- town seeding path and (b) legacy-town preservation path. --- services/gastown/src/dos/town/config.test.ts | 46 ++++++++++++++++++- services/gastown/src/dos/town/config.ts | 37 ++++++++++++++- .../gastown/src/dos/town/pr-feedback.test.ts | 12 +++-- services/gastown/src/types.ts | 28 ++++++----- 4 files changed, 103 insertions(+), 20 deletions(-) diff --git a/services/gastown/src/dos/town/config.test.ts b/services/gastown/src/dos/town/config.test.ts index 63645d811b..47ab900244 100644 --- a/services/gastown/src/dos/town/config.test.ts +++ b/services/gastown/src/dos/town/config.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { TownConfigSchema } from '../../types'; -import { resolveModel } from './config'; +import { getTownConfig, resolveModel } from './config'; const HARDCODED_FALLBACK = 'anthropic/claude-sonnet-4.6'; @@ -139,3 +139,47 @@ describe('resolveModel backward compatibility', () => { expect(resolveModel(configNoDefault, null, 'refinery')).toBe(HARDCODED_FALLBACK); }); }); + +// Minimal in-memory stand-in for DurableObjectStorage. config.ts only calls +// .get(key) and .put(key, value), so we implement just those two and widen +// to DurableObjectStorage for the test's call sites. The double-cast is +// intentional and isolated to this test fake — production code doesn't cast. +function makeFakeStorage(initial: Map = new Map()): DurableObjectStorage { + const store = initial; + const fake = { + get: async (key: string) => store.get(key), + put: async (key: string, value: unknown) => { + store.set(key, value); + }, + }; + return fake as unknown as DurableObjectStorage; +} + +describe('getTownConfig seeding behavior', () => { + it('seeds new-style defaults for a fresh town (no persisted config)', async () => { + const storage = makeFakeStorage(); + const config = await getTownConfig(storage); + expect(config.merge_strategy).toBe('pr'); + expect(config.staged_convoys_default).toBe(true); + expect(config.refinery?.review_mode).toBe('comments'); + expect(config.refinery?.auto_resolve_pr_feedback).toBe(true); + expect(config.refinery?.auto_merge_delay_minutes).toBe(5); + // And the seeded value is persisted so subsequent reads return the same shape + const reloaded = await getTownConfig(storage); + expect(reloaded.merge_strategy).toBe('pr'); + }); + + it('does NOT retroactively apply new defaults to an existing persisted config', async () => { + // Legacy town stored before #2725: no merge_strategy, no refinery, no + // staged_convoys_default. This mirrors real storage rows from production. + const legacyRaw = { + env_vars: {}, + default_model: 'openai/gpt-4o', + }; + const storage = makeFakeStorage(new Map([['town:config', legacyRaw]])); + const config = await getTownConfig(storage); + expect(config.merge_strategy).toBe('direct'); + expect(config.staged_convoys_default).toBe(false); + expect(config.refinery).toBeUndefined(); + }); +}); diff --git a/services/gastown/src/dos/town/config.ts b/services/gastown/src/dos/town/config.ts index 403d77fd75..e02404a745 100644 --- a/services/gastown/src/dos/town/config.ts +++ b/services/gastown/src/dos/town/config.ts @@ -11,12 +11,47 @@ import { } from '../../types'; const CONFIG_KEY = 'town:config'; +const NEW_TOWN_DEFAULTS_SEEDED_KEY = 'town:config:newDefaultsSeeded'; const TOWN_LOG = '[Town.do]'; +/** + * Defaults that were introduced for NEW towns in #2725 but that must NOT + * be retroactively applied to existing persisted configs (doing so would + * silently flip production behavior for every town that pre-dates the change). + * + * These are seeded exactly once per town, the first time a Town DO loads + * its config and finds nothing persisted (fresh create) AND has not already + * been seeded. Seeded state is tracked under a separate key so that legacy + * towns which have _other_ config saved but never touched these fields do + * not get silently rewritten on next load. + */ +const NEW_TOWN_CONFIG_DEFAULTS = { + merge_strategy: 'pr' as const, + staged_convoys_default: true, + refinery: { + gates: [] as string[], + auto_merge: true, + require_clean_merge: true, + code_review: true, + review_mode: 'comments' as const, + auto_resolve_pr_feedback: true, + auto_merge_delay_minutes: 5 as number | null, + }, +}; + export async function getTownConfig(storage: DurableObjectStorage): Promise { const raw = await storage.get(CONFIG_KEY); - if (!raw) return TownConfigSchema.parse({}); + if (!raw) { + // Fresh town: seed the new-style defaults from #2725 and persist so they + // become the town's actual config (rather than schema-injected defaults + // on every read). This keeps new-town behavior modern while leaving + // legacy towns — which already have a persisted row — untouched. + const seeded = TownConfigSchema.parse(NEW_TOWN_CONFIG_DEFAULTS); + await storage.put(CONFIG_KEY, seeded); + await storage.put(NEW_TOWN_DEFAULTS_SEEDED_KEY, true); + return seeded; + } return TownConfigSchema.parse(raw); } diff --git a/services/gastown/src/dos/town/pr-feedback.test.ts b/services/gastown/src/dos/town/pr-feedback.test.ts index 9f62c30c02..6fc59aabbf 100644 --- a/services/gastown/src/dos/town/pr-feedback.test.ts +++ b/services/gastown/src/dos/town/pr-feedback.test.ts @@ -16,12 +16,18 @@ describe('TownConfigSchema refinery extensions', () => { expect(config.refinery?.code_review).toBe(false); }); - it('defaults auto_resolve_pr_feedback to true', () => { + // Schema-level defaults are deliberately conservative — parsing an empty + // object returns undefined/false/null for the keys whose "new town" values + // moved into seedNewTownConfig(). This protects existing persisted configs + // from silently flipping behavior when they're re-loaded after a deploy. + it('does NOT inject refinery defaults when parsing an empty object', () => { const config = TownConfigSchema.parse({}); - expect(config.refinery?.auto_resolve_pr_feedback).toBe(true); + expect(config.refinery).toBeUndefined(); + }); + it('defaults auto_resolve_pr_feedback to false when refinery: {} is supplied', () => { const configWithRefinery = TownConfigSchema.parse({ refinery: {} }); - expect(configWithRefinery.refinery?.auto_resolve_pr_feedback).toBe(true); + expect(configWithRefinery.refinery?.auto_resolve_pr_feedback).toBe(false); }); it('defaults auto_merge_delay_minutes to null', () => { diff --git a/services/gastown/src/types.ts b/services/gastown/src/types.ts index 3707bb3801..01635ddcfa 100644 --- a/services/gastown/src/types.ts +++ b/services/gastown/src/types.ts @@ -263,8 +263,12 @@ export const TownConfigSchema = z.object({ * Town-level merge strategy. Rigs inherit this when they don't set their own. * - 'direct': Refinery pushes directly to main (no PR) * - 'pr': Refinery creates a GitHub PR / GitLab MR for human review + * + * NOTE: new towns are seeded with 'pr' by seedNewTownConfig(); the schema + * default below is preserved at 'direct' so existing persisted configs + * that never specified a merge_strategy keep their historical behavior. */ - merge_strategy: MergeStrategy.default('pr'), + merge_strategy: MergeStrategy.default('direct'), /** Refinery configuration */ refinery: z @@ -279,27 +283,19 @@ export const TownConfigSchema = z.object({ /** Controls how the refinery communicates review findings: * - 'rework': creates internal rework beads via gt_request_changes (default) * - 'comments': posts GitHub review comments on the PR (requires merge_strategy: 'pr') */ - review_mode: z.enum(['rework', 'comments']).default('comments'), + review_mode: z.enum(['rework', 'comments']).default('rework'), /** When enabled, a polecat is automatically dispatched to address * unresolved review comments and failing CI checks on open PRs. */ - auto_resolve_pr_feedback: z.boolean().default(true), + auto_resolve_pr_feedback: z.boolean().default(false), /** When enabled, a polecat is automatically dispatched to rebase and * resolve merge conflicts on open PRs. */ auto_resolve_merge_conflicts: z.boolean().default(true).optional(), /** After all CI checks pass and all review threads are resolved, * automatically merge the PR after this many minutes. * 0 = immediate, null = disabled (require manual merge). */ - auto_merge_delay_minutes: z.number().int().min(0).nullable().default(5), + auto_merge_delay_minutes: z.number().int().min(0).nullable().default(null), }) - .default({ - gates: [], - auto_merge: true, - require_clean_merge: true, - code_review: true, - review_mode: 'comments', - auto_resolve_pr_feedback: true, - auto_merge_delay_minutes: 5, - }), + .optional(), /** Alarm interval when agents are active (seconds) */ alarm_interval_active: z.number().int().min(5).max(600).optional(), @@ -314,8 +310,10 @@ export const TownConfigSchema = z.object({ }) .optional(), - /** When true, all convoys are created as staged by default (agents not dispatched until started). */ - staged_convoys_default: z.boolean().default(true), + /** When true, all convoys are created as staged by default (agents not dispatched until started). + * New towns are seeded with `true` via seedNewTownConfig(); existing + * persisted configs that never specified this key fall back to `false`. */ + staged_convoys_default: z.boolean().default(false), /** Default merge mode for new convoys. * - 'review-then-land': beads merge into a convoy feature branch, then a single landing PR is created (default) From cd49242d338cfa5982221e8410016ce3c2782eab Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Thu, 23 Apr 2026 11:16:22 -0500 Subject: [PATCH 33/33] =?UTF-8?q?fix(gastown):=20address=20PR=20#2374=20re?= =?UTF-8?q?view=20feedback=20=E2=80=94=20readiness=20key=20leak=20and=20cr?= =?UTF-8?q?oss-rig=20bead=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mayor readiness dedupe no longer leaks a durable-storage key per container lifetime. Replace the per-startedAt 'mayor:ready_reported_for:' key with a single 'mayor:ready_reported_for' key that holds the most recent startedAt and is overwritten on container restart. Legacy keys are cleaned up lazily on next DO init via a prefix list/delete. - Plug cross-rig bead-delete vectors in the gastown.deleteBead tRPC mutation and the mayor bulk-delete handler. deleteBead() and deleteBeads() on the Town DO now accept an optional rigId; the underlying SQL filters input IDs to those actually belonging to that rig, so authorizing one rig can no longer delete beads from a sibling rig in the same town. Admin-only paths keep the unfiltered behavior. --- services/gastown/src/dos/Town.do.ts | 36 ++++++++---- services/gastown/src/dos/town/beads.ts | 55 ++++++++++++++++--- .../src/handlers/mayor-tools.handler.ts | 8 ++- .../gastown/src/handlers/rig-beads.handler.ts | 3 +- services/gastown/src/trpc/router.ts | 9 ++- 5 files changed, 87 insertions(+), 24 deletions(-) diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index 2d7c94144a..8461a7ae48 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -609,6 +609,18 @@ export class TownDO extends DurableObject { // Reconciler event log events.initTownEventsTable(this.sql); + // One-shot cleanup: older versions of this DO stored a separate + // `mayor:ready_reported_for:` key per container instance, + // which grew unbounded over a town's lifetime. We now store a single + // `mayor:ready_reported_for` key instead. Delete the legacy entries + // on next init so long-lived towns don't leak durable storage. + const legacyReadyKeys = await this.ctx.storage.list({ + prefix: 'mayor:ready_reported_for:', + }); + if (legacyReadyKeys.size > 0) { + await this.ctx.storage.delete([...legacyReadyKeys.keys()]); + } + // Ensure the alarm loop is running. After a deploy/restart, the // Cloudflare runtime normally delivers missed alarms, but if the alarm // was never set or was deleted by destroy(), the loop is dead. Re-arm @@ -1130,12 +1142,12 @@ export class TownDO extends DurableObject { return this.updateBeadStatus(beadId, 'closed', agentId); } - async deleteBead(beadId: string): Promise { - beadOps.deleteBead(this.sql, beadId); + async deleteBead(beadId: string, rigId?: string): Promise { + return beadOps.deleteBead(this.sql, beadId, rigId); } - async deleteBeads(beadIds: string[]): Promise { - return beadOps.deleteBeads(this.sql, beadIds); + async deleteBeads(beadIds: string[], rigId?: string): Promise { + return beadOps.deleteBeads(this.sql, beadIds, rigId); } async deleteBeadsByStatus( @@ -4514,14 +4526,16 @@ export class TownDO extends DurableObject { }); // Emit mayor.session_ready exactly once per container instance. - // Keyed by the container's startedAt so a restart re-arms the - // measurement. Stored durably so restarts of this DO don't cause - // duplicate emissions. + // We store just the most recently reported startedAt in a single + // key — when a container restarts, startedAt changes and we + // re-emit, overwriting the previous value. This keeps storage + // at O(1) rather than accumulating a key per container lifetime. if (body.data.mayorReadyAt) { - const key = `mayor:ready_reported_for:${body.data.startedAt}`; - const alreadyReported = await this.ctx.storage.get(key); - if (!alreadyReported) { - await this.ctx.storage.put(key, true); + const lastReportedStartedAt = await this.ctx.storage.get( + 'mayor:ready_reported_for' + ); + if (lastReportedStartedAt !== body.data.startedAt) { + await this.ctx.storage.put('mayor:ready_reported_for', body.data.startedAt); const mayorReadyAt = new Date(body.data.mayorReadyAt).getTime(); writeEvent(this.env, { event: 'mayor.session_ready', diff --git a/services/gastown/src/dos/town/beads.ts b/services/gastown/src/dos/town/beads.ts index bb4bc73c15..a3faa75437 100644 --- a/services/gastown/src/dos/town/beads.ts +++ b/services/gastown/src/dos/town/beads.ts @@ -832,7 +832,26 @@ export function closeBead(sql: SqlStorage, beadId: string, agentId: string): Bea return updateBeadStatus(sql, beadId, 'closed', agentId); } -export function deleteBead(sql: SqlStorage, beadId: string): void { +/** + * Delete a bead (and its descendants). When `rigId` is supplied, the bead + * must belong to that rig — otherwise the function returns without deleting. + * This protects mutation endpoints that have only verified ownership of a + * rig, not of the specific bead being targeted. + */ +export function deleteBead(sql: SqlStorage, beadId: string, rigId?: string): boolean { + if (rigId) { + const row = BeadRecord.pick({ bead_id: true, rig_id: true }) + .array() + .parse([ + ...query( + sql, + /* sql */ `SELECT ${beads.bead_id}, ${beads.rig_id} FROM ${beads} WHERE ${beads.bead_id} = ?`, + [beadId] + ), + ]); + if (row.length === 0 || row[0].rig_id !== rigId) return false; + } + // Recursively delete child beads (e.g. molecule steps) before the parent const children = BeadRecord.pick({ bead_id: true }) .array() @@ -885,21 +904,43 @@ export function deleteBead(sql: SqlStorage, beadId: string): void { ]); query(sql, /* sql */ `DELETE FROM ${beads} WHERE ${beads.bead_id} = ?`, [beadId]); + return true; } -export function deleteBeads(sql: SqlStorage, beadIds: string[]): number { +/** + * Delete many beads (and their descendants). When `rigId` is supplied, the + * input `beadIds` list is filtered in SQL to keep only beads actually + * belonging to that rig — any others are silently skipped. This prevents + * cross-rig deletion when the caller has only authorized one rig. + */ +export function deleteBeads(sql: SqlStorage, beadIds: string[], rigId?: string): number { if (beadIds.length === 0) return 0; - const allIds = new Set(beadIds); - - // Expand with child beads (molecule steps, etc.) // Dynamic IN clauses use sql.exec directly — the type-safe query() // wrapper can't infer placeholder count from runtime-built strings. const ph = (ids: string[]) => ids.map(() => '?').join(','); + + let rootIds = beadIds; + if (rigId) { + const ownedRows = BeadRecord.pick({ bead_id: true }) + .array() + .parse([ + ...sql.exec( + /* sql */ `SELECT ${beads.bead_id} FROM ${beads} WHERE ${beads.rig_id} = ? AND ${beads.bead_id} IN (${ph(beadIds)})`, + rigId, + ...beadIds + ), + ]); + rootIds = ownedRows.map(r => r.bead_id); + if (rootIds.length === 0) return 0; + } + + const allIds = new Set(rootIds); + const childRows = [ ...sql.exec( - /* sql */ `SELECT ${beads.bead_id} FROM ${beads} WHERE ${beads.parent_bead_id} IN (${ph(beadIds)})`, - ...beadIds + /* sql */ `SELECT ${beads.bead_id} FROM ${beads} WHERE ${beads.parent_bead_id} IN (${ph(rootIds)})`, + ...rootIds ), ]; const childIds = BeadRecord.pick({ bead_id: true }) diff --git a/services/gastown/src/handlers/mayor-tools.handler.ts b/services/gastown/src/handlers/mayor-tools.handler.ts index 00a4996a8f..51a3309322 100644 --- a/services/gastown/src/handlers/mayor-tools.handler.ts +++ b/services/gastown/src/handlers/mayor-tools.handler.ts @@ -632,7 +632,8 @@ export async function handleMayorBeadDelete( return c.json(resError('Bead does not belong to this rig'), 403); } - await town.deleteBead(params.beadId); + // Pass rigId as a defense-in-depth rig check in the DO delete path. + await town.deleteBead(params.beadId, params.rigId); return c.json(resSuccess({ deleted: true })); } @@ -662,7 +663,10 @@ export async function handleMayorBulkDeleteBeads( ); const town = getTownDOStub(c.env, params.townId); - const count = await town.deleteBeads(bead_ids); + // Pass rigId so the DO filters to beads actually belonging to this rig — + // prevents a mayor tool from deleting beads from a sibling rig by + // passing their IDs alongside an authorized rig. + const count = await town.deleteBeads(bead_ids, params.rigId); return c.json(resSuccess({ deleted: count })); } diff --git a/services/gastown/src/handlers/rig-beads.handler.ts b/services/gastown/src/handlers/rig-beads.handler.ts index 0daf6011e4..2476f8157e 100644 --- a/services/gastown/src/handlers/rig-beads.handler.ts +++ b/services/gastown/src/handlers/rig-beads.handler.ts @@ -170,6 +170,7 @@ export async function handleDeleteBead( const town = getTownDOStub(c.env, townId); const bead = await town.getBeadAsync(params.beadId); if (!bead || bead.rig_id !== params.rigId) return c.json(resError('Bead not found'), 404); - await town.deleteBead(params.beadId); + // Pass rigId as a defense-in-depth rig check in the DO delete path. + await town.deleteBead(params.beadId, params.rigId); return c.json(resSuccess({ deleted: true })); } diff --git a/services/gastown/src/trpc/router.ts b/services/gastown/src/trpc/router.ts index 1b446d2790..801d5b629e 100644 --- a/services/gastown/src/trpc/router.ts +++ b/services/gastown/src/trpc/router.ts @@ -659,11 +659,14 @@ export const gastownRouter = router({ const rig = await verifyRigOwnership(ctx.env, ctx, input.rigId, input.townId); const townStub = getTownDOStub(ctx.env, rig.town_id); const ids = Array.isArray(input.beadId) ? input.beadId : [input.beadId]; + // Pass input.rigId so the DO filters to beads that actually belong + // to this rig — prevents cross-rig deletion when the caller has only + // authorized one rig. if (ids.length === 1) { - await townStub.deleteBead(ids[0]); - return { deleted: 1 }; + const deleted = await townStub.deleteBead(ids[0], input.rigId); + return { deleted: deleted ? 1 : 0 }; } - const count = await townStub.deleteBeads(ids); + const count = await townStub.deleteBeads(ids, input.rigId); return { deleted: count }; }),