Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/db/migrations/0017_gastown.sql
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;
89 changes: 89 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ export function enumCheck<T extends Record<string, string>>(
);
}

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,
Expand All @@ -92,6 +104,8 @@ export const SCHEMA_CHECK_ENUMS = {
KiloPassAuditLogResult,
KiloPassScheduledChangeStatus,
CliSessionSharedState,
GastownTownStatus,
GastownRigStatus,
} as const;

export const credit_transactions = pgTable(
Expand Down Expand Up @@ -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<Record<string, unknown>>().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'`),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The where clause here uses unqualified sql`status = 'active'` but the generated migration SQL (line 36 of 0017_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 next drizzle-kit generate.

Consider table-qualifying it to match:

Suggested change
.where(sql`status = 'active'`),
.where(sql`"gastown_rigs"."status" = 'active'`),

enumCheck('gastown_rigs_status_check', table.status, GastownRigStatus),
]
);

export type GastownRig = typeof gastown_rigs.$inferSelect;
export type NewGastownRig = typeof gastown_rigs.$inferInsert;
7 changes: 7 additions & 0 deletions src/lib/config.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
165 changes: 165 additions & 0 deletions src/lib/gastown/fly-client.ts
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;
}
25 changes: 25 additions & 0 deletions src/lib/gastown/sandbox-id.ts
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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: bytesToBase64url is a plain base64 encoding, not a hash — it expands input by ~33%. The router allows townName up to 100 chars, and userId can be long too. With the gastown: prefix the raw input can easily exceed 47 bytes, producing a base64 string longer than MAX_SANDBOX_ID_LENGTH (63). This means createTown will throw at runtime for many valid town names.

Consider using a hash (e.g. SHA-256 truncated to a fixed length) instead of raw base64 encoding, or tighten the townName max length to something that guarantees the encoded output stays under 63 chars.

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;
}
Loading