Make environments instance-scoped in DB and shared contracts

This commit is contained in:
Devin Foley committed 2026-06-19 22:54:12 -07:00
1 parent 631b7806ed
commit bf62f26497
12 files changed
+201 -12

No files matched your search

@@ -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");
@@ -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
}
]
}
+7 -8
View File
@@ -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<Record<string, unknown>>().notNull().default({}),
envVars: jsonb("env_vars").$type<Record<string, unknown>>().notNull().default({}),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
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),
}),
);
@@ -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<Record<string, unknown>>().notNull().default({}),
experimental: jsonb("experimental").$type<Record<string, unknown>>().notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+1
View File
@@ -897,6 +897,7 @@ export {
} from "./execution-workspace-guards.js";
export {
instanceSettingsSchema,
instanceGeneralSettingsSchema,
patchInstanceGeneralSettingsSchema,
type PatchInstanceGeneralSettings,
+2 -2
View File
@@ -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<string, unknown>;
envVars: AgentEnvConfig;
metadata: Record<string, unknown> | null;
createdAt: Date;
updatedAt: Date;
+1
View File
@@ -60,6 +60,7 @@ export interface InstanceExperimentalSettings {
export interface InstanceSettings {
id: string;
defaultEnvironmentId: string | null;
general: InstanceGeneralSettings;
experimental: InstanceExperimentalSettings;
createdAt: Date;
@@ -162,7 +162,6 @@ export interface ProjectExecutionWorkspacePolicy {
export interface IssueExecutionWorkspaceSettings {
mode?: ExecutionWorkspaceMode;
environmentId?: string | null;
workspaceStrategy?: ExecutionWorkspaceStrategy | null;
workspaceRuntime?: Record<string, unknown> | null;
}
@@ -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<typeof updateEnvironmentSchema>;
@@ -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<typeof probeEnvironmentConfigSchema>;
+1
View File
@@ -1,4 +1,5 @@
export {
instanceSettingsSchema,
instanceGeneralSettingsSchema,
patchInstanceGeneralSettingsSchema,
type InstanceGeneralSettings,
@@ -75,3 +75,12 @@ export type PatchInstanceExperimentalSettings = z.infer<typeof patchInstanceExpe
export type IssueGraphLivenessAutoRecoveryRequest = z.infer<
typeof issueGraphLivenessAutoRecoveryRequestSchema
>;
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();
-1
View File
@@ -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(),
})