-
Notifications
You must be signed in to change notification settings - Fork 5
feat(gastown): Database Schema & Provisioning API #294
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 271-gt-prop-a
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Response> { | ||
| 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<void> { | ||
| 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<string, string>; | ||
| guest?: FlyMachineGuest; | ||
| services?: FlyMachineService[]; | ||
| mounts?: FlyMachineMount[]; | ||
| metadata?: Record<string, string>; | ||
| 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<FlyVolume> { | ||
| 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<void> { | ||
| 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<FlyMachine> { | ||
| 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<void> { | ||
| const resp = await flyFetch(config, `/machines/${machineId}/start`, { method: 'POST' }); | ||
| await assertOk(resp, 'startMachine'); | ||
| } | ||
|
|
||
| export async function stopMachine(config: GastownFlyConfig, machineId: string): Promise<void> { | ||
| 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<void> { | ||
| 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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: Consider using a hash (e.g. SHA-256 truncated to a fixed length) instead of raw base64 encoding, or tighten the |
||
| 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; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SUGGESTION: The
whereclause here uses unqualifiedsql`status = 'active'`but the generated migration SQL (line 36 of0017_gastown.sql) uses the table-qualified form"gastown_rigs"."status" = 'active'. This mismatch may cause Drizzle Kit to detect schema drift and generate a spurious migration on the nextdrizzle-kit generate.Consider table-qualifying it to match: