From 88be0617d7b67248a7849b6456298b56943dd37b Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Tue, 17 Feb 2026 12:35:08 -0600 Subject: [PATCH] feat(gastown): add database schema and provisioning API (#282) Add gastown_towns and gastown_rigs tables with Drizzle schema, Fly.io client, deterministic sandbox ID generation, and tRPC router with createTown, destroyTown, stopTown, startTown, listTowns, getTownStatus, addRig, and removeRig endpoints. Provisioning flow creates a Fly volume and machine with rollback on failure. --- src/db/migrations/0017_gastown.sql | 37 +++ src/db/schema.ts | 89 ++++++ src/lib/config.server.ts | 7 + src/lib/gastown/fly-client.ts | 165 ++++++++++++ src/lib/gastown/sandbox-id.ts | 25 ++ src/routers/gastown-router.ts | 420 +++++++++++++++++++++++++++++ src/routers/root-router.ts | 2 + 7 files changed, 745 insertions(+) create mode 100644 src/db/migrations/0017_gastown.sql create mode 100644 src/lib/gastown/fly-client.ts create mode 100644 src/lib/gastown/sandbox-id.ts create mode 100644 src/routers/gastown-router.ts diff --git a/src/db/migrations/0017_gastown.sql b/src/db/migrations/0017_gastown.sql new file mode 100644 index 000000000..d44cd702a --- /dev/null +++ b/src/db/migrations/0017_gastown.sql @@ -0,0 +1,37 @@ +CREATE TABLE "gastown_towns" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "town_name" text NOT NULL, + "sandbox_id" text NOT NULL, + "fly_machine_id" text, + "fly_volume_id" text, + "fly_region" text DEFAULT 'iad', + "status" text DEFAULT 'provisioning' NOT NULL, + "last_r2_sync_at" timestamp with time zone, + "last_heartbeat_at" timestamp with time zone, + "config" jsonb DEFAULT '{}'::jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "destroyed_at" timestamp with time zone, + CONSTRAINT "gastown_towns_sandbox_id_unique" UNIQUE("sandbox_id"), + CONSTRAINT "gastown_towns_status_check" CHECK ("gastown_towns"."status" IN ('provisioning', 'running', 'stopped', 'destroyed')) +); +--> statement-breakpoint +CREATE UNIQUE INDEX "UQ_gastown_towns_active_per_user_name" ON "gastown_towns" USING btree ("user_id","town_name") WHERE "gastown_towns"."destroyed_at" IS NULL; +--> statement-breakpoint +ALTER TABLE "gastown_towns" ADD CONSTRAINT "gastown_towns_user_id_kilocode_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +CREATE TABLE "gastown_rigs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "town_id" uuid NOT NULL, + "rig_name" text NOT NULL, + "repo_url" text NOT NULL, + "branch" text DEFAULT 'main', + "status" text DEFAULT 'active' NOT NULL, + "config" jsonb DEFAULT '{}'::jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "gastown_rigs_status_check" CHECK ("gastown_rigs"."status" IN ('active', 'removed')) +); +--> statement-breakpoint +CREATE UNIQUE INDEX "UQ_gastown_rigs_town_name" ON "gastown_rigs" USING btree ("town_id","rig_name") WHERE "gastown_rigs"."status" = 'active'; +--> statement-breakpoint +ALTER TABLE "gastown_rigs" ADD CONSTRAINT "gastown_rigs_town_id_gastown_towns_id_fk" FOREIGN KEY ("town_id") REFERENCES "public"."gastown_towns"("id") ON DELETE cascade ON UPDATE no action; diff --git a/src/db/schema.ts b/src/db/schema.ts index 247efce57..00a4c4887 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -83,6 +83,18 @@ export function enumCheck>( ); } +const GastownTownStatus = { + provisioning: 'provisioning', + running: 'running', + stopped: 'stopped', + destroyed: 'destroyed', +} as const; + +const GastownRigStatus = { + active: 'active', + removed: 'removed', +} as const; + export const SCHEMA_CHECK_ENUMS = { KiloPassTier, KiloPassCadence, @@ -92,6 +104,8 @@ export const SCHEMA_CHECK_ENUMS = { KiloPassAuditLogResult, KiloPassScheduledChangeStatus, CliSessionSharedState, + GastownTownStatus, + GastownRigStatus, } as const; export const credit_transactions = pgTable( @@ -2956,3 +2970,78 @@ export const kiloclaw_access_codes = pgTable( ); export type KiloClawAccessCode = typeof kiloclaw_access_codes.$inferSelect; + +// ─── Gastown (sandbox-per-town hosted Gastown) ─────────────────────── + +export const gastown_towns = pgTable( + 'gastown_towns', + { + id: uuid() + .default(sql`gen_random_uuid()`) + .primaryKey() + .notNull(), + user_id: text() + .notNull() + .references(() => kilocode_users.id, { onDelete: 'cascade' }), + town_name: text().notNull(), + sandbox_id: text().notNull().unique(), + fly_machine_id: text(), + fly_volume_id: text(), + fly_region: text().default('iad'), + status: text() + .$type<(typeof GastownTownStatus)[keyof typeof GastownTownStatus]>() + .notNull() + .default('provisioning'), + last_r2_sync_at: timestamp({ withTimezone: true, mode: 'string' }), + last_heartbeat_at: timestamp({ withTimezone: true, mode: 'string' }), + config: jsonb() + .$type<{ + rigs?: unknown; + models?: unknown; + max_polecats?: number; + }>() + .default({}), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + destroyed_at: timestamp({ withTimezone: true, mode: 'string' }), + }, + table => [ + uniqueIndex('UQ_gastown_towns_active_per_user_name') + .on(table.user_id, table.town_name) + .where(isNull(table.destroyed_at)), + enumCheck('gastown_towns_status_check', table.status, GastownTownStatus), + ] +); + +export type GastownTown = typeof gastown_towns.$inferSelect; +export type NewGastownTown = typeof gastown_towns.$inferInsert; + +export const gastown_rigs = pgTable( + 'gastown_rigs', + { + id: uuid() + .default(sql`gen_random_uuid()`) + .primaryKey() + .notNull(), + town_id: uuid() + .notNull() + .references(() => gastown_towns.id, { onDelete: 'cascade' }), + rig_name: text().notNull(), + repo_url: text().notNull(), + branch: text().default('main'), + status: text() + .$type<(typeof GastownRigStatus)[keyof typeof GastownRigStatus]>() + .notNull() + .default('active'), + config: jsonb().$type>().default({}), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + }, + table => [ + uniqueIndex('UQ_gastown_rigs_town_name') + .on(table.town_id, table.rig_name) + .where(sql`status = 'active'`), + enumCheck('gastown_rigs_status_check', table.status, GastownRigStatus), + ] +); + +export type GastownRig = typeof gastown_rigs.$inferSelect; +export type NewGastownRig = typeof gastown_rigs.$inferInsert; diff --git a/src/lib/config.server.ts b/src/lib/config.server.ts index 3978873be..122e1807b 100644 --- a/src/lib/config.server.ts +++ b/src/lib/config.server.ts @@ -143,6 +143,13 @@ export const AGENT_ENV_VARS_PUBLIC_KEY = getEnvVariable('AGENT_ENV_VARS_PUBLIC_K export const KILOCLAW_API_URL = getEnvVariable('KILOCLAW_API_URL') || ''; export const KILOCLAW_INTERNAL_API_SECRET = getEnvVariable('KILOCLAW_INTERNAL_API_SECRET') || ''; +// Gastown (sandbox-per-town) +export const GASTOWN_FLY_API_TOKEN = getEnvVariable('GASTOWN_FLY_API_TOKEN') || ''; +export const GASTOWN_FLY_APP_NAME = getEnvVariable('GASTOWN_FLY_APP_NAME') || ''; +export const GASTOWN_FLY_REGION = getEnvVariable('GASTOWN_FLY_REGION') || 'iad'; +export const GASTOWN_SANDBOX_IMAGE = getEnvVariable('GASTOWN_SANDBOX_IMAGE') || ''; +export const GASTOWN_INTERNAL_API_SECRET = getEnvVariable('GASTOWN_INTERNAL_API_SECRET') || ''; + // Webhook Agent Ingest Worker export const WEBHOOK_AGENT_URL = getEnvVariable('WEBHOOK_AGENT_URL') || 'https://hooks.kilosessions.ai'; diff --git a/src/lib/gastown/fly-client.ts b/src/lib/gastown/fly-client.ts new file mode 100644 index 000000000..98cdb3691 --- /dev/null +++ b/src/lib/gastown/fly-client.ts @@ -0,0 +1,165 @@ +import 'server-only'; + +/** + * Fly.io Machines API client for Gastown sandbox provisioning. + * Modeled on kiloclaw/src/fly/client.ts but adapted for direct + * use from the Next.js backend (server-only). + */ + +const FLY_API_BASE = 'https://api.machines.dev'; + +export type GastownFlyConfig = { + apiToken: string; + appName: string; +}; + +class FlyApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly body: string + ) { + super(message); + this.name = 'FlyApiError'; + } +} + +async function flyFetch( + config: GastownFlyConfig, + path: string, + init?: RequestInit +): Promise { + const url = `${FLY_API_BASE}/v1/apps/${config.appName}${path}`; + return fetch(url, { + ...init, + headers: { + Authorization: `Bearer ${config.apiToken}`, + 'Content-Type': 'application/json', + ...init?.headers, + }, + }); +} + +async function assertOk(resp: Response, context: string): Promise { + if (!resp.ok) { + const body = await resp.text(); + throw new FlyApiError(`Fly API ${context} failed (${resp.status}): ${body}`, resp.status, body); + } +} + +// -- Types (subset of Fly API types needed for Gastown) -- + +type FlyMachineGuest = { + cpus: number; + memory_mb: number; + cpu_kind?: 'shared' | 'performance'; +}; + +type FlyMachineMount = { + volume: string; + path: string; +}; + +type FlyMachineService = { + ports: { port: number; handlers?: string[] }[]; + internal_port: number; + protocol: 'tcp' | 'udp'; +}; + +type FlyMachineConfig = { + image: string; + env?: Record; + guest?: FlyMachineGuest; + services?: FlyMachineService[]; + mounts?: FlyMachineMount[]; + metadata?: Record; + auto_destroy?: boolean; +}; + +type FlyMachine = { + id: string; + name: string; + state: string; + region: string; + config: FlyMachineConfig; + created_at: string; + updated_at: string; +}; + +type FlyVolume = { + id: string; + name: string; + state: string; + size_gb: number; + region: string; +}; + +type CreateVolumeRequest = { + name: string; + region: string; + size_gb: number; +}; + +// -- Volume operations -- + +export async function createVolume( + config: GastownFlyConfig, + request: CreateVolumeRequest +): Promise { + const resp = await flyFetch(config, '/volumes', { + method: 'POST', + body: JSON.stringify(request), + }); + await assertOk(resp, 'createVolume'); + return resp.json(); +} + +export async function deleteVolume(config: GastownFlyConfig, volumeId: string): Promise { + const resp = await flyFetch(config, `/volumes/${volumeId}`, { method: 'DELETE' }); + await assertOk(resp, 'deleteVolume'); +} + +// -- Machine operations -- + +export async function createMachine( + config: GastownFlyConfig, + machineConfig: FlyMachineConfig, + options?: { name?: string; region?: string } +): Promise { + const body = { + config: machineConfig, + name: options?.name, + region: options?.region, + }; + const resp = await flyFetch(config, '/machines', { + method: 'POST', + body: JSON.stringify(body), + }); + await assertOk(resp, 'createMachine'); + return resp.json(); +} + +export async function startMachine(config: GastownFlyConfig, machineId: string): Promise { + const resp = await flyFetch(config, `/machines/${machineId}/start`, { method: 'POST' }); + await assertOk(resp, 'startMachine'); +} + +export async function stopMachine(config: GastownFlyConfig, machineId: string): Promise { + const resp = await flyFetch(config, `/machines/${machineId}/stop`, { method: 'POST' }); + await assertOk(resp, 'stopMachine'); +} + +export async function destroyMachine( + config: GastownFlyConfig, + machineId: string, + force = true +): Promise { + const resp = await flyFetch(config, `/machines/${machineId}?force=${force}`, { + method: 'DELETE', + }); + await assertOk(resp, 'destroyMachine'); +} + +export function isFlyNotFound(err: unknown): boolean { + return err instanceof FlyApiError && err.status === 404; +} diff --git a/src/lib/gastown/sandbox-id.ts b/src/lib/gastown/sandbox-id.ts new file mode 100644 index 000000000..bd94d4c1e --- /dev/null +++ b/src/lib/gastown/sandbox-id.ts @@ -0,0 +1,25 @@ +/** + * Derive a deterministic sandbox ID from a user ID and town name. + * + * Similar to kiloclaw/sandbox-id.ts but incorporates the town name + * so each user can have multiple towns with unique sandbox IDs. + */ + +const MAX_SANDBOX_ID_LENGTH = 63; + +function bytesToBase64url(bytes: Uint8Array): string { + const binString = Array.from(bytes, b => String.fromCodePoint(b)).join(''); + return btoa(binString).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +export function gastownSandboxId(userId: string, townName: string): string { + const input = `gastown:${userId}:${townName}`; + const bytes = new TextEncoder().encode(input); + const encoded = bytesToBase64url(bytes); + if (encoded.length > MAX_SANDBOX_ID_LENGTH) { + throw new Error( + `sandbox ID too long: encoded would be ${encoded.length} chars (max ${MAX_SANDBOX_ID_LENGTH})` + ); + } + return encoded; +} diff --git a/src/routers/gastown-router.ts b/src/routers/gastown-router.ts new file mode 100644 index 000000000..8b59688f6 --- /dev/null +++ b/src/routers/gastown-router.ts @@ -0,0 +1,420 @@ +import 'server-only'; + +import * as z from 'zod'; +import { TRPCError } from '@trpc/server'; +import { and, eq, isNull } from 'drizzle-orm'; +import { baseProcedure, createTRPCRouter } from '@/lib/trpc/init'; +import { db } from '@/lib/drizzle'; +import { gastown_towns, gastown_rigs } from '@/db/schema'; +import { gastownSandboxId } from '@/lib/gastown/sandbox-id'; +import { + GASTOWN_FLY_API_TOKEN, + GASTOWN_FLY_APP_NAME, + GASTOWN_FLY_REGION, + GASTOWN_SANDBOX_IMAGE, + GASTOWN_INTERNAL_API_SECRET, +} from '@/lib/config.server'; +import { + createVolume, + createMachine, + startMachine, + stopMachine, + destroyMachine, + deleteVolume, + isFlyNotFound, +} from '@/lib/gastown/fly-client'; +import type { GastownFlyConfig } from '@/lib/gastown/fly-client'; +import { generateApiToken, TOKEN_EXPIRY } from '@/lib/tokens'; + +function getFlyConfig(): GastownFlyConfig { + if (!GASTOWN_FLY_API_TOKEN) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Gastown Fly API not configured', + }); + } + if (!GASTOWN_FLY_APP_NAME) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Gastown Fly app not configured', + }); + } + if (!GASTOWN_SANDBOX_IMAGE) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Gastown sandbox image not configured', + }); + } + return { apiToken: GASTOWN_FLY_API_TOKEN, appName: GASTOWN_FLY_APP_NAME }; +} + +const GASTOWN_VOLUME_SIZE_GB = 50; +const GASTOWN_VOLUME_MOUNT_PATH = '/data'; + +export const gastownRouter = createTRPCRouter({ + createTown: baseProcedure + .input(z.object({ townName: z.string().min(1).max(100) })) + .mutation(async ({ ctx, input }) => { + const flyConfig = getFlyConfig(); + const sandboxId = gastownSandboxId(ctx.user.id, input.townName); + const region = GASTOWN_FLY_REGION; + + // Insert DB row with status=provisioning + const [town] = await db + .insert(gastown_towns) + .values({ + user_id: ctx.user.id, + town_name: input.townName, + sandbox_id: sandboxId, + fly_region: region, + status: 'provisioning', + }) + .returning(); + + if (!town) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to create town row', + }); + } + + let volumeId: string | undefined; + try { + // Mint a gateway JWT for the sandbox (stub — PR 5 will implement full refresh) + const gatewayJwt = generateApiToken(ctx.user, undefined, { + expiresIn: TOKEN_EXPIRY.thirtyDays, + }); + + // Create persistent volume + const volume = await createVolume(flyConfig, { + name: `gastown-${sandboxId}`, + region, + size_gb: GASTOWN_VOLUME_SIZE_GB, + }); + volumeId = volume.id; + + // Create Fly machine + const internalApiKey = GASTOWN_INTERNAL_API_SECRET || crypto.randomUUID(); + const machine = await createMachine( + flyConfig, + { + image: GASTOWN_SANDBOX_IMAGE, + guest: { cpus: 4, memory_mb: 8192, cpu_kind: 'shared' }, + env: { + KILO_API_URL: process.env.KILO_API_URL ?? 'https://api.kilo.ai', + KILO_JWT: gatewayJwt, + TOWN_ID: town.id, + SANDBOX_ID: sandboxId, + INTERNAL_API_KEY: internalApiKey, + }, + mounts: [{ volume: volume.id, path: GASTOWN_VOLUME_MOUNT_PATH }], + metadata: { + gastown_town_id: town.id, + gastown_user_id: ctx.user.id, + }, + }, + { name: `gastown-${sandboxId}`, region } + ); + + // Update DB row with Fly IDs and mark running + const [updated] = await db + .update(gastown_towns) + .set({ + fly_machine_id: machine.id, + fly_volume_id: volume.id, + fly_region: machine.region, + status: 'running', + }) + .where(eq(gastown_towns.id, town.id)) + .returning(); + + if (!updated) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to update town status', + }); + } + + return { id: updated.id, status: updated.status }; + } catch (error) { + // Best-effort cleanup of Fly resources created before failure + if (volumeId) { + try { + await deleteVolume(flyConfig, volumeId); + } catch { + // Volume cleanup is best-effort; health monitor will reconcile + } + } + // Soft-delete the DB row + await db + .update(gastown_towns) + .set({ status: 'destroyed', destroyed_at: new Date().toISOString() }) + .where(eq(gastown_towns.id, town.id)); + throw error; + } + }), + + destroyTown: baseProcedure + .input(z.object({ townId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const flyConfig = getFlyConfig(); + + const [town] = await db + .select() + .from(gastown_towns) + .where( + and( + eq(gastown_towns.id, input.townId), + eq(gastown_towns.user_id, ctx.user.id), + isNull(gastown_towns.destroyed_at) + ) + ) + .limit(1); + + if (!town) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Town not found' }); + } + + // Soft-delete first + await db + .update(gastown_towns) + .set({ status: 'destroyed', destroyed_at: new Date().toISOString() }) + .where(eq(gastown_towns.id, town.id)); + + // Destroy Fly resources (best-effort; row is already soft-deleted) + if (town.fly_machine_id) { + try { + await destroyMachine(flyConfig, town.fly_machine_id); + } catch (err) { + if (!isFlyNotFound(err)) throw err; + } + } + if (town.fly_volume_id) { + try { + await deleteVolume(flyConfig, town.fly_volume_id); + } catch (err) { + if (!isFlyNotFound(err)) throw err; + } + } + + return { id: town.id, status: 'destroyed' satisfies typeof town.status }; + }), + + getTownStatus: baseProcedure + .input(z.object({ townId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + const [town] = await db + .select({ + id: gastown_towns.id, + town_name: gastown_towns.town_name, + status: gastown_towns.status, + fly_region: gastown_towns.fly_region, + last_heartbeat_at: gastown_towns.last_heartbeat_at, + last_r2_sync_at: gastown_towns.last_r2_sync_at, + config: gastown_towns.config, + created_at: gastown_towns.created_at, + }) + .from(gastown_towns) + .where( + and( + eq(gastown_towns.id, input.townId), + eq(gastown_towns.user_id, ctx.user.id), + isNull(gastown_towns.destroyed_at) + ) + ) + .limit(1); + + if (!town) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Town not found' }); + } + + return town; + }), + + listTowns: baseProcedure.query(async ({ ctx }) => { + return db + .select({ + id: gastown_towns.id, + town_name: gastown_towns.town_name, + status: gastown_towns.status, + fly_region: gastown_towns.fly_region, + last_heartbeat_at: gastown_towns.last_heartbeat_at, + created_at: gastown_towns.created_at, + }) + .from(gastown_towns) + .where(and(eq(gastown_towns.user_id, ctx.user.id), isNull(gastown_towns.destroyed_at))); + }), + + stopTown: baseProcedure + .input(z.object({ townId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const flyConfig = getFlyConfig(); + + const [town] = await db + .select() + .from(gastown_towns) + .where( + and( + eq(gastown_towns.id, input.townId), + eq(gastown_towns.user_id, ctx.user.id), + isNull(gastown_towns.destroyed_at) + ) + ) + .limit(1); + + if (!town) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Town not found' }); + } + if (town.status !== 'running') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Cannot stop town in status: ${town.status}`, + }); + } + if (!town.fly_machine_id) { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Town has no Fly machine' }); + } + + await stopMachine(flyConfig, town.fly_machine_id); + + const [updated] = await db + .update(gastown_towns) + .set({ status: 'stopped' }) + .where(eq(gastown_towns.id, town.id)) + .returning({ id: gastown_towns.id, status: gastown_towns.status }); + + if (!updated) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to update town status', + }); + } + return updated; + }), + + startTown: baseProcedure + .input(z.object({ townId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const flyConfig = getFlyConfig(); + + const [town] = await db + .select() + .from(gastown_towns) + .where( + and( + eq(gastown_towns.id, input.townId), + eq(gastown_towns.user_id, ctx.user.id), + isNull(gastown_towns.destroyed_at) + ) + ) + .limit(1); + + if (!town) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Town not found' }); + } + if (town.status !== 'stopped') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Cannot start town in status: ${town.status}`, + }); + } + if (!town.fly_machine_id) { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Town has no Fly machine' }); + } + + await startMachine(flyConfig, town.fly_machine_id); + + const [updated] = await db + .update(gastown_towns) + .set({ status: 'running' }) + .where(eq(gastown_towns.id, town.id)) + .returning({ id: gastown_towns.id, status: gastown_towns.status }); + + if (!updated) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to update town status', + }); + } + return updated; + }), + + addRig: baseProcedure + .input( + z.object({ + townId: z.string().uuid(), + rigName: z.string().min(1).max(100), + repoUrl: z.string().url(), + branch: z.string().optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + // Verify town ownership + const [town] = await db + .select({ id: gastown_towns.id }) + .from(gastown_towns) + .where( + and( + eq(gastown_towns.id, input.townId), + eq(gastown_towns.user_id, ctx.user.id), + isNull(gastown_towns.destroyed_at) + ) + ) + .limit(1); + + if (!town) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Town not found' }); + } + + const [rig] = await db + .insert(gastown_rigs) + .values({ + town_id: input.townId, + rig_name: input.rigName, + repo_url: input.repoUrl, + branch: input.branch ?? 'main', + }) + .returning(); + + return rig; + }), + + removeRig: baseProcedure + .input(z.object({ townId: z.string().uuid(), rigName: z.string() })) + .mutation(async ({ ctx, input }) => { + // Verify town ownership + const [town] = await db + .select({ id: gastown_towns.id }) + .from(gastown_towns) + .where( + and( + eq(gastown_towns.id, input.townId), + eq(gastown_towns.user_id, ctx.user.id), + isNull(gastown_towns.destroyed_at) + ) + ) + .limit(1); + + if (!town) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Town not found' }); + } + + const [removed] = await db + .update(gastown_rigs) + .set({ status: 'removed' }) + .where( + and( + eq(gastown_rigs.town_id, input.townId), + eq(gastown_rigs.rig_name, input.rigName), + eq(gastown_rigs.status, 'active') + ) + ) + .returning({ id: gastown_rigs.id, rig_name: gastown_rigs.rig_name }); + + if (!removed) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Rig not found' }); + } + + return removed; + }), +}); diff --git a/src/routers/root-router.ts b/src/routers/root-router.ts index 1928fb805..51484772c 100644 --- a/src/routers/root-router.ts +++ b/src/routers/root-router.ts @@ -30,6 +30,7 @@ import { webhookTriggersRouter } from '@/routers/webhook-triggers-router'; import { userFeedbackRouter } from '@/routers/user-feedback-router'; import { appBuilderFeedbackRouter } from '@/routers/app-builder-feedback-router'; import { kiloclawRouter } from '@/routers/kiloclaw-router'; +import { gastownRouter } from '@/routers/gastown-router'; export const rootRouter = createTRPCRouter({ test: testRouter, @@ -62,6 +63,7 @@ export const rootRouter = createTRPCRouter({ userFeedback: userFeedbackRouter, appBuilderFeedback: appBuilderFeedbackRouter, kiloclaw: kiloclawRouter, + gastown: gastownRouter, }); // export type definition of API export type RootRouter = typeof rootRouter;