From 6ff3297010f5f6da646165b4202ee643a6e4039f Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 21 Mar 2026 15:56:19 +0000 Subject: [PATCH] feat(gastown): add POST /debug/reconcile-dry-run endpoint Add a debug endpoint that runs the reconciler against current live state and returns the actions it would emit without applying them. This enables inspecting what the reconciler thinks should happen at any given moment. - Add debugDryRun() method to TownDO that calls reconciler.reconcile() and returns actions + metrics without calling applyAction() - Add POST /debug/towns/:townId/reconcile-dry-run route following the same unauthenticated debug pattern as GET /debug/towns/:townId/status - Response includes actions array, actionsEmitted count, actionsByType breakdown, and pendingEventCount --- cloudflare-gastown/src/dos/Town.do.ts | 27 +++++++++++++++++++++++- cloudflare-gastown/src/gastown.worker.ts | 8 +++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index 0cf7c974a7..6fdcc33692 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -30,7 +30,7 @@ import * as scheduling from './town/scheduling'; import * as events from './town/events'; import * as reconciler from './town/reconciler'; import { applyAction } from './town/actions'; -import type { ApplyActionContext } from './town/actions'; +import type { Action, ApplyActionContext } from './town/actions'; import { buildRefinerySystemPrompt } from '../prompts/refinery-system.prompt'; import { GitHubPRStatusSchema, GitLabMRStatusSchema } from '../util/platform-pr.util'; @@ -3684,6 +3684,31 @@ export class TownDO extends DurableObject { }; } + // DEBUG: dry-run the reconciler against current state, returning actions + // it would emit without applying them. Side-effect-free — reconcile() + // only reads SQLite state; applyAction() is never called. + async debugDryRun(): Promise<{ + actions: Action[]; + metrics: Pick< + reconciler.ReconcilerMetrics, + 'actionsEmitted' | 'actionsByType' | 'pendingEventCount' + >; + }> { + const actions = reconciler.reconcile(this.sql); + const actionsByType: Record = {}; + for (const a of actions) { + actionsByType[a.type] = (actionsByType[a.type] ?? 0) + 1; + } + return { + actions, + metrics: { + actionsEmitted: actions.length, + actionsByType, + pendingEventCount: events.pendingEventCount(this.sql), + }, + }; + } + // DEBUG: concise non-terminal bead summary — remove after debugging async debugBeadSummary(): Promise { return [ diff --git a/cloudflare-gastown/src/gastown.worker.ts b/cloudflare-gastown/src/gastown.worker.ts index 6c8efab713..1a1634f0d2 100644 --- a/cloudflare-gastown/src/gastown.worker.ts +++ b/cloudflare-gastown/src/gastown.worker.ts @@ -206,6 +206,14 @@ app.get('/debug/towns/:townId/status', async c => { return c.json({ alarmStatus, agentMeta, beadSummary }); }); +app.post('/debug/towns/:townId/reconcile-dry-run', async c => { + const townId = c.req.param('townId'); + const town = getTownDOStub(c.env, townId); + // eslint-disable-next-line @typescript-eslint/await-thenable -- DO RPC returns promise at runtime + const result = await town.debugDryRun(); + return c.json(result); +}); + // ── Town ID + Auth ────────────────────────────────────────────────────── // All rig routes live under /api/towns/:townId/rigs/:rigId so the townId // is always available from the URL path.