diff --git a/packages/db/src/migrations/0105_instance_scoped_environments.sql b/packages/db/src/migrations/0105_instance_scoped_environments.sql new file mode 100644 index 00000000000..7b0e0b11b55 --- /dev/null +++ b/packages/db/src/migrations/0105_instance_scoped_environments.sql @@ -0,0 +1,167 @@ +ALTER TABLE "environments" ADD COLUMN "env_vars" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint +ALTER TABLE "instance_settings" ADD COLUMN "default_environment_id" uuid;--> statement-breakpoint +DO $$ +BEGIN + CREATE TEMP TABLE environment_migration_map ( + old_id uuid PRIMARY KEY, + new_id uuid NOT NULL + ) ON COMMIT DROP; + + WITH ranked AS ( + SELECT + e.id, + CASE + WHEN e.driver = 'local' THEN '__paperclip_builtin_local__' + WHEN e.driver = 'sandbox' AND (e.metadata ->> 'managedByPaperclip')::boolean = true + THEN '__paperclip_managed_sandbox__' + ELSE e.driver || ':' || e.name + END AS group_key, + row_number() OVER ( + PARTITION BY CASE + WHEN e.driver = 'local' THEN '__paperclip_builtin_local__' + WHEN e.driver = 'sandbox' AND (e.metadata ->> 'managedByPaperclip')::boolean = true + THEN '__paperclip_managed_sandbox__' + ELSE e.driver || ':' || e.name + END + ORDER BY e.created_at ASC, e.id ASC + ) AS rn + FROM "environments" e + ), + canonical AS ( + SELECT + ranked.group_key, + ranked.id AS canonical_id + FROM ranked + WHERE ranked.rn = 1 + ) + INSERT INTO environment_migration_map (old_id, new_id) + SELECT ranked.id, canonical.canonical_id + FROM ranked + JOIN canonical ON canonical.group_key = ranked.group_key; + + UPDATE "agents" AS agents + SET "default_environment_id" = map.new_id + FROM environment_migration_map AS map + WHERE agents."default_environment_id" = map.old_id + AND agents."default_environment_id" IS DISTINCT FROM map.new_id; + + UPDATE "environment_leases" AS leases + SET "environment_id" = map.new_id + FROM environment_migration_map AS map + WHERE leases."environment_id" = map.old_id + AND leases."environment_id" IS DISTINCT FROM map.new_id; + + DELETE FROM "environments" AS environments + USING environment_migration_map AS map + WHERE environments."id" = map.old_id + AND map.old_id <> map.new_id; +END $$; +--> statement-breakpoint +UPDATE "issues" +SET "execution_workspace_settings" = "execution_workspace_settings" - 'environmentId' +WHERE jsonb_typeof("execution_workspace_settings") = 'object' + AND "execution_workspace_settings" ? 'environmentId'; +--> statement-breakpoint +DROP INDEX IF EXISTS "environments_company_managed_sandbox_idx";--> statement-breakpoint +DROP INDEX IF EXISTS "environments_company_status_idx";--> statement-breakpoint +DROP INDEX IF EXISTS "environments_company_driver_idx";--> statement-breakpoint +DROP INDEX IF EXISTS "environments_company_name_idx";--> statement-breakpoint +ALTER TABLE "environments" DROP CONSTRAINT IF EXISTS "environments_company_id_companies_id_fk";--> statement-breakpoint +ALTER TABLE "environments" DROP COLUMN "company_id";--> statement-breakpoint +INSERT INTO "environments" ( + "id", + "name", + "description", + "driver", + "status", + "config", + "env_vars", + "metadata", + "created_at", + "updated_at" +) +SELECT + gen_random_uuid(), + 'Local', + 'Default execution environment for Paperclip runs on this machine.', + 'local', + 'active', + '{}'::jsonb, + '{}'::jsonb, + '{"managedByPaperclip": true, "defaultForInstance": true}'::jsonb, + now(), + now() +WHERE NOT EXISTS ( + SELECT 1 + FROM "environments" + WHERE "driver" = 'local' +); +--> statement-breakpoint +UPDATE "environments" +SET + "metadata" = COALESCE("metadata", '{}'::jsonb) || '{"managedByPaperclip": true, "defaultForInstance": true}'::jsonb, + "updated_at" = now() +WHERE "driver" = 'local'; +--> statement-breakpoint +WITH duplicate_names AS ( + SELECT + "id", + "name", + "driver", + row_number() OVER (PARTITION BY "name" ORDER BY "created_at" ASC, "id" ASC) AS rn + FROM "environments" +) +UPDATE "environments" AS environments +SET + "name" = duplicate_names."name" || ' (' || duplicate_names."driver" || ' ' || substring(duplicate_names."id"::text from 1 for 8) || ')', + "updated_at" = now() +FROM duplicate_names +WHERE environments."id" = duplicate_names."id" + AND duplicate_names.rn > 1; +--> statement-breakpoint +INSERT INTO "instance_settings" ( + "id", + "singleton_key", + "default_environment_id", + "general", + "experimental", + "created_at", + "updated_at" +) +SELECT + gen_random_uuid(), + 'default', + local_env."id", + '{}'::jsonb, + '{}'::jsonb, + now(), + now() +FROM ( + SELECT "id" + FROM "environments" + WHERE "driver" = 'local' + ORDER BY "created_at" ASC, "id" ASC + LIMIT 1 +) AS local_env +ON CONFLICT ("singleton_key") DO UPDATE +SET + "default_environment_id" = EXCLUDED."default_environment_id", + "updated_at" = now(); +--> statement-breakpoint +ALTER TABLE "instance_settings" + ADD CONSTRAINT "instance_settings_default_environment_id_environments_id_fk" + FOREIGN KEY ("default_environment_id") + REFERENCES "public"."environments"("id") + ON DELETE set null + ON UPDATE no action; +--> statement-breakpoint +CREATE INDEX "environments_status_idx" ON "environments" USING btree ("status");--> statement-breakpoint +CREATE UNIQUE INDEX "environments_local_driver_idx" + ON "environments" USING btree ("driver") + WHERE "driver" = 'local'; +--> statement-breakpoint +CREATE UNIQUE INDEX "environments_managed_sandbox_idx" + ON "environments" USING btree ("driver") + WHERE "driver" = 'sandbox' AND ("metadata" ->> 'managedByPaperclip')::boolean = true; +--> statement-breakpoint +CREATE UNIQUE INDEX "environments_name_idx" ON "environments" USING btree ("name"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 67009dffd22..ae94c3645b3 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -736,6 +736,13 @@ "when": 1781733000000, "tag": "0104_issue_watchdogs", "breakpoints": true + }, + { + "idx": 105, + "version": "7", + "when": 1781902000000, + "tag": "0105_instance_scoped_environments", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/environments.ts b/packages/db/src/schema/environments.ts index e64e5c4107c..a61c2a99ae1 100644 --- a/packages/db/src/schema/environments.ts +++ b/packages/db/src/schema/environments.ts @@ -1,31 +1,30 @@ import { sql } from "drizzle-orm"; import { index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; -import { companies } from "./companies.js"; export const environments = pgTable( "environments", { id: uuid("id").primaryKey().defaultRandom(), - companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), name: text("name").notNull(), description: text("description"), driver: text("driver").notNull().default("local"), status: text("status").notNull().default("active"), config: jsonb("config").$type>().notNull().default({}), + envVars: jsonb("env_vars").$type>().notNull().default({}), metadata: jsonb("metadata").$type>(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => ({ - companyStatusIdx: index("environments_company_status_idx").on(table.companyId, table.status), - companyDriverIdx: uniqueIndex("environments_company_driver_idx") - .on(table.companyId, table.driver) + statusIdx: index("environments_status_idx").on(table.status), + localDriverIdx: uniqueIndex("environments_local_driver_idx") + .on(table.driver) .where(sql`${table.driver} = 'local'`), - companyManagedSandboxIdx: uniqueIndex("environments_company_managed_sandbox_idx") - .on(table.companyId) + managedSandboxIdx: uniqueIndex("environments_managed_sandbox_idx") + .on(table.driver) .where( sql`${table.driver} = 'sandbox' AND (${table.metadata} ->> 'managedByPaperclip')::boolean = true`, ), - companyNameIdx: index("environments_company_name_idx").on(table.companyId, table.name), + nameIdx: uniqueIndex("environments_name_idx").on(table.name), }), ); diff --git a/packages/db/src/schema/instance_settings.ts b/packages/db/src/schema/instance_settings.ts index 002df259ef7..456d04b8947 100644 --- a/packages/db/src/schema/instance_settings.ts +++ b/packages/db/src/schema/instance_settings.ts @@ -1,10 +1,12 @@ import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core"; +import { environments } from "./environments.js"; export const instanceSettings = pgTable( "instance_settings", { id: uuid("id").primaryKey().defaultRandom(), singletonKey: text("singleton_key").notNull().default("default"), + defaultEnvironmentId: uuid("default_environment_id").references(() => environments.id, { onDelete: "set null" }), general: jsonb("general").$type>().notNull().default({}), experimental: jsonb("experimental").$type>().notNull().default({}), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 04e4ceabfe4..2a5ca16754b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -897,6 +897,7 @@ export { } from "./execution-workspace-guards.js"; export { + instanceSettingsSchema, instanceGeneralSettingsSchema, patchInstanceGeneralSettingsSchema, type PatchInstanceGeneralSettings, diff --git a/packages/shared/src/types/environment.ts b/packages/shared/src/types/environment.ts index 68f3e724dda..1e085282737 100644 --- a/packages/shared/src/types/environment.ts +++ b/packages/shared/src/types/environment.ts @@ -5,7 +5,7 @@ import type { EnvironmentLeaseStatus, EnvironmentStatus, } from "../constants.js"; -import type { EnvSecretRefBinding } from "./secrets.js"; +import type { AgentEnvConfig, EnvSecretRefBinding } from "./secrets.js"; export interface LocalEnvironmentConfig { [key: string]: unknown; @@ -56,12 +56,12 @@ export interface EnvironmentProbeResult { export interface Environment { id: string; - companyId: string; name: string; description: string | null; driver: EnvironmentDriver; status: EnvironmentStatus; config: Record; + envVars: AgentEnvConfig; metadata: Record | null; createdAt: Date; updatedAt: Date; diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index ab9075ee4fe..893a1cda4f3 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -60,6 +60,7 @@ export interface InstanceExperimentalSettings { export interface InstanceSettings { id: string; + defaultEnvironmentId: string | null; general: InstanceGeneralSettings; experimental: InstanceExperimentalSettings; createdAt: Date; diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 13ba4154bca..6035ba91ca4 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -162,7 +162,6 @@ export interface ProjectExecutionWorkspacePolicy { export interface IssueExecutionWorkspaceSettings { mode?: ExecutionWorkspaceMode; - environmentId?: string | null; workspaceStrategy?: ExecutionWorkspaceStrategy | null; workspaceRuntime?: Record | null; } diff --git a/packages/shared/src/validators/environment.ts b/packages/shared/src/validators/environment.ts index cadd97eea24..2639bb5c43a 100644 --- a/packages/shared/src/validators/environment.ts +++ b/packages/shared/src/validators/environment.ts @@ -5,6 +5,7 @@ import { ENVIRONMENT_LEASE_STATUSES, ENVIRONMENT_STATUSES, } from "../constants.js"; +import { envConfigSchema } from "./secret.js"; export const environmentDriverSchema = z.enum(ENVIRONMENT_DRIVERS); export const environmentStatusSchema = z.enum(ENVIRONMENT_STATUSES); @@ -17,6 +18,7 @@ const environmentFields = { driver: environmentDriverSchema, status: environmentStatusSchema.optional().default("active"), config: z.record(z.string(), z.unknown()).optional().default({}), + envVars: envConfigSchema.optional().default({}), metadata: z.record(z.string(), z.unknown()).optional().nullable(), }; @@ -29,6 +31,7 @@ export const updateEnvironmentSchema = z.object({ driver: environmentDriverSchema.optional(), status: environmentStatusSchema.optional(), config: z.record(z.string(), z.unknown()).optional(), + envVars: envConfigSchema.optional(), metadata: z.record(z.string(), z.unknown()).optional().nullable(), }).strict(); export type UpdateEnvironment = z.infer; @@ -38,6 +41,7 @@ export const probeEnvironmentConfigSchema = z.object({ description: z.string().optional().nullable(), driver: environmentDriverSchema, config: z.record(z.string(), z.unknown()).optional().default({}), + envVars: envConfigSchema.optional().default({}), metadata: z.record(z.string(), z.unknown()).optional().nullable(), }).strict(); export type ProbeEnvironmentConfig = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index d21e15c74d1..43b178c2cab 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -1,4 +1,5 @@ export { + instanceSettingsSchema, instanceGeneralSettingsSchema, patchInstanceGeneralSettingsSchema, type InstanceGeneralSettings, diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 7b944fb6830..0d9be2f1c1e 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -75,3 +75,12 @@ export type PatchInstanceExperimentalSettings = z.infer; + +export const instanceSettingsSchema = z.object({ + id: z.string().uuid(), + defaultEnvironmentId: z.string().uuid().nullable(), + general: instanceGeneralSettingsSchema, + experimental: instanceExperimentalSettingsSchema, + createdAt: z.union([z.date(), z.string().datetime()]), + updatedAt: z.union([z.date(), z.string().datetime()]), +}).strict(); diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index f1e8fcb95e0..f022f703cd3 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -117,7 +117,6 @@ const executionWorkspaceStrategySchema = z export const issueExecutionWorkspaceSettingsSchema = z .object({ mode: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional(), - environmentId: z.string().uuid().optional().nullable(), workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(), workspaceRuntime: z.record(z.string(), z.unknown()).optional().nullable(), })