From 913102870d1e217852ea9bffd7daa66bdd43a595 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Fri, 6 Nov 2026 15:01:58 -0800 Subject: [PATCH] fix(environments): fill the managed instance default once, under a lock (#19632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - On a managed instance under `enableManagedSandboxOnly`, boot points the instance default at the managed sandbox when nothing deliberate is set > - That write read the default and wrote it later without a lock. Two overlapping boots, or a boot and an admin write, could overwrite a value the other had just committed > - A revert path also reset that stamped default whenever the mode was turned off, so a platform-filled default could change again later > - A planned plugin-environment default should fill only an unset default and never change a set one, which needs every instance's default to stay set > - This pull request makes the fill one locked transaction and removes the revert, so a filled default is an ordinary set value > - The benefit is a single, race-free rule: boot fills an unset default once, and nothing changes a set default afterwards ## Linked Issues or Issue Description Refs #19551 (the plugin-environment default that builds on this rule) **Subsystem affected** Server environments: the managed environment bootstrap and the instance settings route. **Problem or motivation** `applyManagedEnvironments` read the instance default, decided, and wrote it in separate steps with no lock, so a concurrent boot or admin write could be overwritten. When `enableManagedSandboxOnly` was not declared, a revert reset a default the platform had filled. That needed a `managedDefaultStamped` marker, plus a marker clear on every admin default write. A later boot could therefore change a default that had already been set. **Proposed solution** Fill the default in one transaction that locks the instance default row and rechecks it. Fill only an unset, local or deleted default. Remove the revert and its marker, so a filled default behaves like any other set value. **Alternatives considered** Keeping the revert and adding locks to it. A revert on a mode change would still move an existing instance's default later, which a fill-only plugin default cannot tolerate. **Roadmap alignment** This narrows existing managed-environment behaviour. It adds no product surface. ## What Changed - New `server/src/services/managed-instance-default.ts`: `fillUnsetInstanceDefault(db, environmentId)`. In one transaction it creates the settings row if needed, locks the instance default row (`FOR UPDATE`), and rechecks the current value. It writes only when the default is unset, names the local environment, or names a deleted environment. Any other set default is kept. - `server/src/services/managed-environments.ts`: - Under `enableManagedSandboxOnly`, the managed sandbox fill now uses that helper, through a new `fillInstanceDefault` test seam. - The mode-off revert and the `managedDefaultStamped` marker are removed. - The unused `instanceSettings` seam option is removed, along with the `getById`, `update` and `findManagedSandboxEnvironment` methods of the environments seam. - `server/src/routes/instance-settings.ts`: an explicit default write is a plain `update` again. The marker clear and its transaction are gone. - `doc/DEVELOPING.md`: the "Managed environments" section states the fill rule. Existing rows may still carry `managedDefaultStamped` in their metadata. Nothing reads it any more, and it is not part of the managed stock hash. ## Verification - `cd server && npx vitest run src/__tests__/managed-instance-default.test.ts` (embedded Postgres): - The fill writes an unset default, including on a fresh instance with no settings row. It also writes a default that names the local environment, or one whose environment was deleted. - It never changes a default set to an ssh, plugin or other sandbox environment, and leaves a default that already names the target alone. - Controlled overlap: an uncommitted admin default write makes the fill wait, and the fill then keeps the admin's value. This test fails without the row lock. - Two overlapping fills: exactly one writes. - Real boot pass: the default is filled under managed-sandbox-only and stays the same after a boot with the mode off. A set default stays on every boot. - `npx vitest run src/services/managed-environments.test.ts src/services/managed-environments-plugin-entries.test.ts`: the fill runs only under managed-sandbox-only, with the managed sandbox row. It never runs for a plugin environment. Nothing is written when the mode is off. A failed fill does not fail the ensure. - `npx vitest run src/__tests__/instance-settings-routes.test.ts`: an explicit default write is a plain update that touches no environment row. - `npx vitest run src/__tests__/managed-plugin-environments.test.ts src/__tests__/environment-routes.test.ts src/__tests__/server-startup-feedback-export.test.ts src/__tests__/instance-settings-service.test.ts`: pass. - `pnpm --filter @paperclipai/server typecheck` passes. - I did not run the full `pnpm test:run`, `pnpm -r typecheck` or `pnpm build`. CI covers them. ## Risks - Behaviour change: when `enableManagedSandboxOnly` is turned off for an instance, a default the platform filled with the managed sandbox now stays. Local execution is still allowed then, but it is no longer the default unless an admin picks it. - No schema change. Stale `managedDefaultStamped` keys stay in some row metadata and are ignored. - The fill holds the instance default row lock for one short transaction at boot. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code with tool use (file edits, shell, test runs). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- doc/DEVELOPING.md | 8 + .../instance-settings-routes.test.ts | 61 +------ .../managed-instance-default.test.ts | 160 ++++++++++++++++++ server/src/routes/instance-settings.ts | 24 +-- ...anaged-environments-plugin-entries.test.ts | 36 +--- .../managed-environments.test-support.ts | 15 -- .../src/services/managed-environments.test.ts | 160 +++--------------- server/src/services/managed-environments.ts | 101 ++--------- .../src/services/managed-instance-default.ts | 60 +++++++ 9 files changed, 294 insertions(+), 331 deletions(-) create mode 100644 server/src/__tests__/managed-instance-default.test.ts create mode 100644 server/src/services/managed-instance-default.ts diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index fc5d1031e0c..62107e72ab4 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -2054,6 +2054,14 @@ unmarked row for the same plugin and driver. While the plugin is down, the row i archived; it comes back when the worker is ready. Removing an entry never archives its row. Plugin entries do not change the instance default. +Under `enableManagedSandboxOnly`, boot fills the instance default with the +managed sandbox only when the default is unset, names the local environment, or +names a deleted one. The fill locks the default row and rechecks it, so a +concurrent boot or admin write is never overwritten. A filled default is an +ordinary set value: nothing reverts it when the mode is later turned off, and an +admin can change it in instance settings. + + ### Experimental native remote workspace storage `experimental.remoteWorkspaceStorageEnabled` is a managed feature, on by default diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index eb5c0879cd2..f0705810ebd 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -357,10 +357,9 @@ describe("instance settings routes", () => { .send({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" }); expect(patchRes.status).toBe(200); - expect(mockInstanceSettingsService.update).toHaveBeenCalledWith( - { defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" }, - { db: TX_SENTINEL }, - ); + expect(mockInstanceSettingsService.update).toHaveBeenCalledWith({ + defaultEnvironmentId: "11111111-1111-4111-8111-111111111111", + }); expect(mockLogActivity).toHaveBeenCalledTimes(2); }); @@ -376,19 +375,9 @@ describe("instance settings routes", () => { expect(mockInstanceSettingsService.update).not.toHaveBeenCalled(); }); - it("clears the managed-default stamp marker on an explicit tenant default write", async () => { - // A tenant write of defaultEnvironmentId reclassifies the default as - // tenant-chosen: the reconciliation stamp marker on the managed - // sandbox row must not survive, or a later managed-sandbox-only - // mode-off pass would mistake the tenant's choice for a stamp. - mockEnvironmentService.findManagedSandboxEnvironment.mockResolvedValue({ - id: "managed-env-1", - driver: "sandbox", - status: "active", - config: {}, - envVars: {}, - metadata: { managedByPaperclip: true, managedDefaultStamped: true }, - }); + it("writes an explicit default as a plain set value, touching no environment row", async () => { + // Boot only fills an unset, local or dangling default, so an admin's + // choice needs no marker and no sibling write to stay theirs. const app = await createApp(localAdminActor()); const patchRes = await request(app) @@ -396,42 +385,10 @@ describe("instance settings routes", () => { .send({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" }); expect(patchRes.status).toBe(200); - expect(mockEnvironmentService.update).toHaveBeenCalledWith( - "managed-env-1", - { metadata: { managedByPaperclip: true } }, - { db: TX_SENTINEL }, - ); - // Both writes commit in ONE transaction — each receives the SAME tx — - // so no partial failure can desync the stamp marker from the default - // (neither a stale stamp on a tenant choice, nor a reconciliation - // default that lost its marker and can never revert). - expect(mockInstanceSettingsService.update).toHaveBeenCalledWith( - { defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" }, - { db: TX_SENTINEL }, - ); - }); - - it("aborts the whole request (no committed settings) when the stamp-marker clear fails inside the transaction", async () => { - // The marker clear and the settings write share a transaction, so a - // failure in either rolls the whole thing back — a real DB would - // discard both; here the settings write is never even reached. - mockEnvironmentService.findManagedSandboxEnvironment.mockResolvedValue({ - id: "managed-env-1", - driver: "sandbox", - status: "active", - config: {}, - envVars: {}, - metadata: { managedByPaperclip: true, managedDefaultStamped: true }, + expect(mockInstanceSettingsService.update).toHaveBeenCalledWith({ + defaultEnvironmentId: "11111111-1111-4111-8111-111111111111", }); - mockEnvironmentService.update.mockRejectedValue(new Error("metadata write failed")); - const app = await createApp(localAdminActor()); - - const patchRes = await request(app) - .patch("/api/instance/settings") - .send({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" }); - - expect(patchRes.status).toBeGreaterThanOrEqual(500); - expect(mockInstanceSettingsService.update).not.toHaveBeenCalled(); + expect(mockEnvironmentService.update).not.toHaveBeenCalled(); }); it("rejects unknown defaultEnvironmentId values with 422", async () => { diff --git a/server/src/__tests__/managed-instance-default.test.ts b/server/src/__tests__/managed-instance-default.test.ts new file mode 100644 index 00000000000..597ae7cf3a3 --- /dev/null +++ b/server/src/__tests__/managed-instance-default.test.ts @@ -0,0 +1,160 @@ +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createDb, environments, instanceSettings, type Db } from "@paperclipai/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { fillUnsetInstanceDefault } from "../services/managed-instance-default.js"; +import { applyManagedEnvironments } from "../services/managed-environments.js"; +import { MANAGED_CONFIG_ENV_KEY, parseManagedConfigEnv } from "../services/managed-config.js"; + +const support = await getEmbeddedPostgresTestSupport(); +(support.supported ? describe : describe.skip)("managed instance default (locked fill)", () => { + let database: Awaited>; + let db: Db; + let sandboxId: string; + + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("paperclip-managed-instance-default-"); + db = createDb(database.connectionString); + }, 90_000); + afterAll(async () => { await database?.cleanup(); }); + beforeEach(async () => { + await db.delete(instanceSettings); + await db.delete(environments); + const [sandbox] = await db.insert(environments).values({ + name: "Managed Sandbox", + driver: "sandbox", + status: "active", + config: { provider: "daytona" }, + metadata: { managedByPaperclip: true, managedSandboxProvider: "daytona" }, + }).returning(); + sandboxId = sandbox!.id; + }); + + const instanceDefault = async () => + (await db.select().from(instanceSettings).where(eq(instanceSettings.singletonKey, "default")))[0]?.defaultEnvironmentId ?? null; + const setInstanceDefault = async (environmentId: string | null) => { + const [row] = await db.select().from(instanceSettings).where(eq(instanceSettings.singletonKey, "default")); + if (row) await db.update(instanceSettings).set({ defaultEnvironmentId: environmentId }).where(eq(instanceSettings.id, row.id)); + else await db.insert(instanceSettings).values({ singletonKey: "default", defaultEnvironmentId: environmentId }); + }; + const environment = async (name: string, driver: string) => { + const [row] = await db.insert(environments).values({ name, driver, status: "active", config: {} }).returning(); + return row!.id; + }; + + it("fills an unset default, including on a fresh instance with no settings row", async () => { + expect(await fillUnsetInstanceDefault(db, sandboxId)).toEqual({ filled: true, previousDefaultEnvironmentId: null }); + expect(await instanceDefault()).toBe(sandboxId); + }); + + it("fills a default that names the local environment", async () => { + const localId = await environment("Local", "local"); + await setInstanceDefault(localId); + expect(await fillUnsetInstanceDefault(db, sandboxId)).toEqual({ filled: true, previousDefaultEnvironmentId: localId }); + expect(await instanceDefault()).toBe(sandboxId); + }); + + it("fills a default whose environment was deleted (the reference is cleared)", async () => { + const goneId = await environment("Gone", "ssh"); + await setInstanceDefault(goneId); + await db.delete(environments).where(eq(environments.id, goneId)); + expect(await fillUnsetInstanceDefault(db, sandboxId)).toMatchObject({ filled: true }); + expect(await instanceDefault()).toBe(sandboxId); + }); + + it("never changes a set default, whatever chose it", async () => { + for (const driver of ["ssh", "plugin", "sandbox"]) { + const chosenId = await environment(`Chosen ${driver}`, driver); + await setInstanceDefault(chosenId); + expect(await fillUnsetInstanceDefault(db, sandboxId)).toEqual({ filled: false, previousDefaultEnvironmentId: chosenId }); + expect(await instanceDefault()).toBe(chosenId); + } + }); + + it("leaves a default that already names the target alone", async () => { + await setInstanceDefault(sandboxId); + expect(await fillUnsetInstanceDefault(db, sandboxId)).toEqual({ filled: false, previousDefaultEnvironmentId: sandboxId }); + }); + + it("waits for an overlapping default write and then keeps it (controlled overlap)", async () => { + const chosenId = await environment("Admin choice", "ssh"); + await setInstanceDefault(null); + // An admin's default write holds the row while the fill starts. + let releaseWrite!: () => void; + const writeHeld = new Promise((resolve) => { releaseWrite = resolve; }); + let locked!: () => void; + const rowLocked = new Promise((resolve) => { locked = resolve; }); + const adminWrite = db.transaction(async (tx) => { + await tx.update(instanceSettings).set({ defaultEnvironmentId: chosenId }).where(eq(instanceSettings.singletonKey, "default")); + locked(); + await writeHeld; + }); + await rowLocked; + + let settled = false; + const fill = fillUnsetInstanceDefault(db, sandboxId).then((result) => { settled = true; return result; }); + await new Promise((resolve) => setTimeout(resolve, 200)); + // An unlocked read-then-write would already have filled the stale "unset". + expect(settled).toBe(false); + + releaseWrite(); + await adminWrite; + expect(await fill).toEqual({ filled: false, previousDefaultEnvironmentId: chosenId }); + expect(await instanceDefault()).toBe(chosenId); + }); + + it("lets only one of two overlapping boots fill", async () => { + const results = await Promise.all([fillUnsetInstanceDefault(db, sandboxId), fillUnsetInstanceDefault(db, sandboxId)]); + expect(results.filter((result) => result.filled)).toHaveLength(1); + expect(await instanceDefault()).toBe(sandboxId); + }); + + describe("boot (applyManagedEnvironments)", () => { + const managedConfig = (features: Record) => parseManagedConfigEnv({ + [MANAGED_CONFIG_ENV_KEY]: JSON.stringify({ + v: 1, + mode: "cloud", + catalogVersion: "2026.1106.0", + features, + plugins: { autoInstall: ["daytona"] }, + environments: [{ name: "Managed Sandbox", provider: "daytona" }], + }), + }); + const boot = async (features: Record) => { + const [row] = await db.select().from(environments).where(eq(environments.id, sandboxId)); + return applyManagedEnvironments(db, managedConfig(features), { + env: {}, + workerManager: { isRunning: () => true, getWorker: () => undefined }, + environments: { + ensureManagedSandboxEnvironment: vi.fn(async () => ({ + environment: row as never, + action: "unchanged" as const, + stockStatus: "stock_current" as const, + updateAvailable: false, + stockHash: "sha256:stock", + })), + archiveManagedSandboxEnvironment: vi.fn(async () => null), + ensureManagedPluginEnvironment: vi.fn(), + archiveManagedPluginEnvironment: vi.fn(async () => null), + }, + resolveSandboxProviderDriver: async () => ({ plugin: { id: "daytona-plugin", pluginKey: "daytona", status: "ready" } }), + }); + }; + + it("fills the default under managed-sandbox-only, and never reverts it when the mode is off", async () => { + await boot({ enableManagedSandboxOnly: true }); + expect(await instanceDefault()).toBe(sandboxId); + + await boot({}); + expect(await instanceDefault()).toBe(sandboxId); + }); + + it("keeps a set default on every boot", async () => { + const chosenId = await environment("Admin choice", "ssh"); + await setInstanceDefault(chosenId); + await boot({ enableManagedSandboxOnly: true }); + await boot({ enableManagedSandboxOnly: true }); + expect(await instanceDefault()).toBe(chosenId); + }); + }); +}); diff --git a/server/src/routes/instance-settings.ts b/server/src/routes/instance-settings.ts index c8c89e664d6..1e0ce20ab6f 100644 --- a/server/src/routes/instance-settings.ts +++ b/server/src/routes/instance-settings.ts @@ -162,26 +162,10 @@ export function instanceSettingsRoutes(db: Db, pluginWorkers?: PluginWorkerManag await assertAgentsReadyForEnvironmentChange(db, await environmentById(previousId), await environmentById(req.body.defaultEnvironmentId), { inheritsDefault: true }); } - // An explicit tenant write of the instance default reclassifies its - // attribution: whatever the default becomes — including a deliberate - // re-selection of the managed sandbox row — it is tenant-chosen, so - // the reconciliation stamp marker must not survive to let a later - // managed-sandbox-only mode-off pass mistake the tenant's choice for a - // stamp and revert it. The marker clear and the settings write commit - // in ONE transaction, so no partial failure can desync attribution - // from the default (neither a stale stamp on a tenant choice, nor a - // reconciliation default that lost its marker and can never revert). - const writesDefault = Object.prototype.hasOwnProperty.call(req.body, "defaultEnvironmentId"); - const managedSandbox = writesDefault - ? await environments.findManagedSandboxEnvironment(undefined, { includeArchived: true }) - : null; - const updated = await db.transaction(async (tx) => { - if (managedSandbox?.metadata?.managedDefaultStamped === true) { - const { managedDefaultStamped: _cleared, ...remainingMetadata } = managedSandbox.metadata; - await environments.update(managedSandbox.id, { metadata: remainingMetadata }, { db: tx }); - } - return svc.update(req.body, { db: tx }); - }); + // An explicit default write is an ordinary set value: boot only fills an + // unset, local or dangling default (`fillUnsetInstanceDefault`), and the + // lock it takes on this row means it never overwrites a concurrent write. + const updated = await svc.update(req.body); const actor = getActorInfo(req); const companyIds = await svc.listCompanyIds(); await Promise.all( diff --git a/server/src/services/managed-environments-plugin-entries.test.ts b/server/src/services/managed-environments-plugin-entries.test.ts index 7f2386c959a..26577624a32 100644 --- a/server/src/services/managed-environments-plugin-entries.test.ts +++ b/server/src/services/managed-environments-plugin-entries.test.ts @@ -5,7 +5,6 @@ import { idleWorkSnapshot } from "./task-admission.js"; import { PLUGIN_KEY, environmentsSeam, - instanceSettingsSeam, noDb, parsedConfig, pluginEnvironmentRow, @@ -13,7 +12,6 @@ import { reconciliationResult, recoveryHandle, tick, - type InstanceSettingsSeam, type RecoveryHandle, } from "./managed-environments.test-support.js"; @@ -48,7 +46,6 @@ describe("applyManagedEnvironments plugin entries", () => { env: {}, workerManager: workerManager(), environments, - instanceSettings: instanceSettingsSeam(), resolveEnvironmentPlugin: readyPlugin(), autoInstalledPluginKeys: [PLUGIN_KEY], }); @@ -88,7 +85,6 @@ describe("applyManagedEnvironments plugin entries", () => { env: {}, workerManager: workerManager(), environments, - instanceSettings: instanceSettingsSeam(), resolveSandboxProviderDriver: readyDriverResolver(), resolveEnvironmentPlugin: readyPlugin(), autoInstalledPluginKeys: [PLUGIN_KEY], @@ -102,7 +98,6 @@ describe("applyManagedEnvironments plugin entries", () => { env: {}, workerManager: workerManager(), environments, - instanceSettings: instanceSettingsSeam(), resolveEnvironmentPlugin: readyPlugin(), autoInstalledPluginKeys: ["paperclip.daytona-sandbox-provider"], })).rejects.toThrow(/uses plugin "example.remote-host-plugin", which no "plugins.autoInstall" catalog entry installs/); @@ -116,7 +111,6 @@ describe("applyManagedEnvironments plugin entries", () => { env: { PAPERCLIP_BUNDLED_PLUGIN_ROOT: "/nonexistent-paperclip-catalog" }, workerManager: workerManager(), environments: environmentsSeam(), - instanceSettings: instanceSettingsSeam(), })).rejects.toThrow(/auto-install key "remote-host" is not in the bundled catalog/); }); @@ -125,7 +119,6 @@ describe("applyManagedEnvironments plugin entries", () => { env: { PAPERCLIP_EXECUTION_MODE: "kubernetes" }, workerManager: workerManager(), environments: environmentsSeam(), - instanceSettings: instanceSettingsSeam(), resolveEnvironmentPlugin: readyPlugin(), autoInstalledPluginKeys: [PLUGIN_KEY], })).resolves.toMatchObject({ ensured: 1 }); @@ -141,7 +134,6 @@ describe("applyManagedEnvironments plugin entries", () => { env: {}, workerManager: manager, environments, - instanceSettings: instanceSettingsSeam(), resolveEnvironmentPlugin: readyPlugin(), autoInstalledPluginKeys: [PLUGIN_KEY], }); @@ -170,7 +162,6 @@ describe("applyManagedEnvironments plugin entries", () => { env: {}, workerManager: manager, environments, - instanceSettings: instanceSettingsSeam(), resolveEnvironmentPlugin: readyPlugin(), autoInstalledPluginKeys: [PLUGIN_KEY], }); @@ -202,7 +193,6 @@ describe("applyManagedEnvironments plugin entries", () => { env: {}, workerManager: manager, environments: environmentsSeam({ ensureManagedPluginEnvironment }), - instanceSettings: instanceSettingsSeam(), resolveEnvironmentPlugin: readyPlugin(), autoInstalledPluginKeys: [PLUGIN_KEY], }); @@ -226,7 +216,6 @@ describe("applyManagedEnvironments plugin entries", () => { env: {}, workerManager: manager, environments: environmentsSeam({ ensureManagedPluginEnvironment }), - instanceSettings: instanceSettingsSeam(), resolveEnvironmentPlugin: readyPlugin(), autoInstalledPluginKeys: [PLUGIN_KEY], }); @@ -254,7 +243,6 @@ describe("applyManagedEnvironments plugin entries", () => { env: {}, workerManager: manager, environments, - instanceSettings: instanceSettingsSeam(), resolveEnvironmentPlugin: readyPlugin(), autoInstalledPluginKeys: [PLUGIN_KEY], }); @@ -282,7 +270,6 @@ describe("applyManagedEnvironments plugin entries", () => { env: {}, workerManager: workerManager(true, handle), environments, - instanceSettings: instanceSettingsSeam(), resolveEnvironmentPlugin: readyPlugin(), autoInstalledPluginKeys: [PLUGIN_KEY], }); @@ -308,7 +295,6 @@ describe("applyManagedEnvironments plugin entries", () => { env: {}, workerManager: manager, environments, - instanceSettings: instanceSettingsSeam(), resolveEnvironmentPlugin: readyPlugin(), autoInstalledPluginKeys: [PLUGIN_KEY], }); @@ -335,7 +321,6 @@ describe("applyManagedEnvironments plugin entries", () => { env: {}, workerManager: manager, environments, - instanceSettings: instanceSettingsSeam(), resolveEnvironmentPlugin: readyPlugin(), autoInstalledPluginKeys: [PLUGIN_KEY], }); @@ -363,7 +348,6 @@ describe("applyManagedEnvironments plugin entries", () => { env: {}, workerManager: manager, environments: environmentsSeam({ ensureManagedPluginEnvironment: ensureManagedPluginEnvironment as never }), - instanceSettings: instanceSettingsSeam(), resolveEnvironmentPlugin: readyPlugin(), autoInstalledPluginKeys: [PLUGIN_KEY], recovery, @@ -400,8 +384,7 @@ describe("applyManagedEnvironments plugin entries", () => { env: {}, workerManager: manager, environments, - instanceSettings: instanceSettingsSeam(), - resolveEnvironmentPlugin, + resolveEnvironmentPlugin, autoInstalledPluginKeys: [PLUGIN_KEY], }); expect(result).toMatchObject({ ensured: 0, failed: 1 }); @@ -417,28 +400,28 @@ describe("applyManagedEnvironments plugin entries", () => { env: {}, workerManager: workerManager(), environments, - instanceSettings: instanceSettingsSeam(), })).toBeNull(); expect(environments.archiveManagedPluginEnvironment).not.toHaveBeenCalled(); }); - it("never changes the instance default, and the sandbox stamp never replaces a plugin default", async () => { - const settings = instanceSettingsSeam({ - get: vi.fn().mockResolvedValue({ defaultEnvironmentId: "plugin-env" }) as InstanceSettingsSeam["get"], - }); + it("never fills the instance default with a plugin environment", async () => { + // Only the managed sandbox fills an unset default, through the locked + // fill, which keeps any set default (a plugin environment included). + const fillInstanceDefault = vi.fn(async () => ({ filled: false, previousDefaultEnvironmentId: "plugin-env" })); await applyManagedEnvironments(noDb, configWith( [{ name: "Daytona", provider: "daytona" }, pluginEntry()], { enableManagedSandboxOnly: true }, ), { env: {}, workerManager: workerManager(), - instanceSettings: settings, - environments: environmentsSeam({ getById: vi.fn().mockResolvedValue(pluginEnvironmentRow()) }), + fillInstanceDefault, + environments: environmentsSeam(), resolveSandboxProviderDriver: readyDriverResolver(), resolveEnvironmentPlugin: readyPlugin(), autoInstalledPluginKeys: [PLUGIN_KEY], }); - expect(settings.update).not.toHaveBeenCalled(); + expect(fillInstanceDefault).toHaveBeenCalledTimes(1); + expect(fillInstanceDefault).toHaveBeenCalledWith("env-1"); }); it("is fail-safe: an ensure failure is logged and counted, not thrown", async () => { @@ -448,7 +431,6 @@ describe("applyManagedEnvironments plugin entries", () => { environments: environmentsSeam({ ensureManagedPluginEnvironment: vi.fn().mockRejectedValue(new Error("validator refused")), }), - instanceSettings: instanceSettingsSeam(), resolveEnvironmentPlugin: readyPlugin(), autoInstalledPluginKeys: [PLUGIN_KEY], }); diff --git a/server/src/services/managed-environments.test-support.ts b/server/src/services/managed-environments.test-support.ts index a3019692692..f285d138714 100644 --- a/server/src/services/managed-environments.test-support.ts +++ b/server/src/services/managed-environments.test-support.ts @@ -94,7 +94,6 @@ export function tick() { } export type EnvironmentsSeam = NonNullable; -export type InstanceSettingsSeam = NonNullable; export function environmentsSeam(overrides: Partial = {}): EnvironmentsSeam { return { @@ -102,10 +101,6 @@ export function environmentsSeam(overrides: Partial = {}): Env overrides.ensureManagedSandboxEnvironment ?? vi.fn().mockResolvedValue(reconciliationResult()), archiveManagedSandboxEnvironment: overrides.archiveManagedSandboxEnvironment ?? vi.fn().mockResolvedValue(null), - getById: overrides.getById ?? vi.fn().mockResolvedValue(null), - update: overrides.update ?? vi.fn().mockResolvedValue(environmentRow()), - findManagedSandboxEnvironment: - overrides.findManagedSandboxEnvironment ?? vi.fn().mockResolvedValue(null), ensureManagedPluginEnvironment: overrides.ensureManagedPluginEnvironment ?? vi.fn().mockResolvedValue({ environment: pluginEnvironmentRow(), action: "added" }), @@ -127,13 +122,3 @@ export function pluginEnvironmentRow(overrides: Record = {}) { }) as never; } -export function instanceSettingsSeam(overrides: Partial = {}): InstanceSettingsSeam { - return { - get: - overrides.get ?? - (vi.fn().mockResolvedValue({ defaultEnvironmentId: null }) as InstanceSettingsSeam["get"]), - update: - overrides.update ?? - (vi.fn().mockResolvedValue({ defaultEnvironmentId: "env-1" }) as InstanceSettingsSeam["update"]), - }; -} diff --git a/server/src/services/managed-environments.test.ts b/server/src/services/managed-environments.test.ts index bf8ec41e6bc..db2f8016217 100644 --- a/server/src/services/managed-environments.test.ts +++ b/server/src/services/managed-environments.test.ts @@ -3,7 +3,6 @@ import { applyManagedEnvironments } from "./managed-environments.js"; import { environmentRow, environmentsSeam, - instanceSettingsSeam, noDb, parsedConfig, readyDriverResolver, @@ -11,7 +10,6 @@ import { recoveryHandle, runningWorkerManager, tick, - type InstanceSettingsSeam, } from "./managed-environments.test-support.js"; describe("applyManagedEnvironments", () => { @@ -51,7 +49,6 @@ describe("applyManagedEnvironments", () => { const result = await applyManagedEnvironments(noDb, config, { env: {}, workerManager, - instanceSettings: instanceSettingsSeam(), environments: environmentsSeam({ ensureManagedSandboxEnvironment }), resolveSandboxProviderDriver, }); @@ -92,7 +89,6 @@ describe("applyManagedEnvironments", () => { env: {}, pluginsReady, workerManager: runningWorkerManager(), - instanceSettings: instanceSettingsSeam(), environments: environmentsSeam({ ensureManagedSandboxEnvironment }), resolveSandboxProviderDriver: readyDriverResolver(), }); @@ -289,8 +285,7 @@ describe("applyManagedEnvironments", () => { await applyManagedEnvironments(noDb, config, { env: {}, workerManager, - instanceSettings: instanceSettingsSeam(), - environments: environmentsSeam(), + environments: environmentsSeam(), resolveSandboxProviderDriver, }); expect(workerManager.getWorker).not.toHaveBeenCalled(); @@ -378,8 +373,8 @@ describe("applyManagedEnvironments", () => { expect(environments.archiveManagedSandboxEnvironment).not.toHaveBeenCalled(); }); - it("points an unset instance default at the managed sandbox environment", async () => { - const instanceSettings = instanceSettingsSeam(); + it("fills the instance default with the managed sandbox under managed-sandbox-only", async () => { + const fillInstanceDefault = vi.fn(async () => ({ filled: true, previousDefaultEnvironmentId: null })); const config = parsedConfig({ features: { enableManagedSandboxOnly: true }, environments: [{ name: "Daytona", provider: "daytona" }], @@ -388,73 +383,19 @@ describe("applyManagedEnvironments", () => { await applyManagedEnvironments(noDb, config, { env: {}, workerManager: runningWorkerManager(), - instanceSettings, + fillInstanceDefault, environments: environmentsSeam(), resolveSandboxProviderDriver: readyDriverResolver(), }); - expect(instanceSettings.update).toHaveBeenCalledWith({ defaultEnvironmentId: "env-1" }); - }); - - it("records the stamp marker on the managed row so a revert is attributable", async () => { - const instanceSettings = instanceSettingsSeam(); - const environments = environmentsSeam(); - const config = parsedConfig({ - features: { enableManagedSandboxOnly: true }, - environments: [{ name: "Daytona", provider: "daytona" }], - }); - - await applyManagedEnvironments(noDb, config, { - env: {}, - workerManager: runningWorkerManager(), - instanceSettings, - environments, - resolveSandboxProviderDriver: readyDriverResolver(), - }); - - expect(environments.update).toHaveBeenCalledWith("env-1", { - metadata: expect.objectContaining({ managedDefaultStamped: true }), - }); - }); - - it("moves a local (or dangling) instance default onto the managed sandbox environment", async () => { - const localDefault = instanceSettingsSeam({ - get: vi.fn().mockResolvedValue({ defaultEnvironmentId: "env-local" }) as InstanceSettingsSeam["get"], - }); - const config = parsedConfig({ - features: { enableManagedSandboxOnly: true }, - environments: [{ name: "Daytona", provider: "daytona" }], - }); - - await applyManagedEnvironments(noDb, config, { - env: {}, - workerManager: runningWorkerManager(), - instanceSettings: localDefault, - environments: environmentsSeam({ - getById: vi.fn().mockResolvedValue(environmentRow({ id: "env-local", driver: "local" })), - }), - resolveSandboxProviderDriver: readyDriverResolver(), - }); - expect(localDefault.update).toHaveBeenCalledWith({ defaultEnvironmentId: "env-1" }); - - // A default pointing at a deleted row is repaired the same way. - const danglingDefault = instanceSettingsSeam({ - get: vi.fn().mockResolvedValue({ defaultEnvironmentId: "env-gone" }) as InstanceSettingsSeam["get"], - }); - await applyManagedEnvironments(noDb, config, { - env: {}, - workerManager: runningWorkerManager(), - instanceSettings: danglingDefault, - environments: environmentsSeam({ getById: vi.fn().mockResolvedValue(null) }), - resolveSandboxProviderDriver: readyDriverResolver(), - }); - expect(danglingDefault.update).toHaveBeenCalledWith({ defaultEnvironmentId: "env-1" }); + // The locked fill (`fillUnsetInstanceDefault`) decides whether to write. + expect(fillInstanceDefault).toHaveBeenCalledWith("env-1"); }); it("leaves the instance default alone while managed-sandbox-only is not declared", async () => { // Without the mode, local execution is a legitimate default; provisioning // the managed environment must not silently move runs into the sandbox. - const instanceSettings = instanceSettingsSeam(); + const fillInstanceDefault = vi.fn(async () => ({ filled: true, previousDefaultEnvironmentId: null })); const config = parsedConfig({ environments: [{ name: "Daytona", provider: "daytona" }], }); @@ -462,89 +403,40 @@ describe("applyManagedEnvironments", () => { await applyManagedEnvironments(noDb, config, { env: {}, workerManager: runningWorkerManager(), - instanceSettings, + fillInstanceDefault, environments: environmentsSeam(), resolveSandboxProviderDriver: readyDriverResolver(), }); - expect(instanceSettings.update).not.toHaveBeenCalled(); + expect(fillInstanceDefault).not.toHaveBeenCalled(); }); - it("reverts a stamped managed default when managed-sandbox-only turns off", async () => { - // Turning the mode off must not keep routing default-following runs - // into the sandbox off the stale stamp — even when the document no - // longer declares any environments or the row was archived, which - // skip per-entry reconciliation entirely. Only a default pointing at - // the managed row AND carrying the reconciliation stamp reverts. - const stampedRow = environmentRow({ - status: "archived", - metadata: { managedByPaperclip: true, managedDefaultStamped: true }, - }); - const stamped = instanceSettingsSeam({ - get: vi.fn().mockResolvedValue({ defaultEnvironmentId: "env-1" }) as InstanceSettingsSeam["get"], - }); - const stampedEnvironments = environmentsSeam({ - findManagedSandboxEnvironment: vi.fn().mockResolvedValue(stampedRow), - }); - // No environments declaration at all: the cleanup must still run. - const config = parsedConfig({}); - - await applyManagedEnvironments(noDb, config, { + it("never reverts a filled default when managed-sandbox-only is not declared", async () => { + // A filled default is an ordinary set value. With the mode off, and even + // with no environments declared, boot writes nothing to the default. + const fillInstanceDefault = vi.fn(async () => ({ filled: true, previousDefaultEnvironmentId: null })); + const environments = environmentsSeam(); + expect(await applyManagedEnvironments(noDb, parsedConfig({}), { env: {}, workerManager: runningWorkerManager(), - instanceSettings: stamped, - environments: stampedEnvironments, - resolveSandboxProviderDriver: readyDriverResolver(), - }); - expect(stampedEnvironments.findManagedSandboxEnvironment).toHaveBeenCalledWith(undefined, { - includeArchived: true, - }); - expect(stamped.update).toHaveBeenCalledWith({ defaultEnvironmentId: null }); - // The marker clears with the revert so a later deliberate tenant - // selection of the managed row is never mistaken for a stamp. - expect(stampedEnvironments.update).toHaveBeenCalledWith("env-1", { - metadata: { managedByPaperclip: true }, - }); - - // The tenant's own deliberate managed-row default (no stamp marker) - // survives the mode turning off. - const tenantManagedDefault = instanceSettingsSeam({ - get: vi.fn().mockResolvedValue({ defaultEnvironmentId: "env-1" }) as InstanceSettingsSeam["get"], - }); - const unmarkedEnvironments = environmentsSeam({ - findManagedSandboxEnvironment: vi.fn().mockResolvedValue( - environmentRow({ metadata: { managedByPaperclip: true } }), - ), - }); - await applyManagedEnvironments(noDb, config, { - env: {}, - workerManager: runningWorkerManager(), - instanceSettings: tenantManagedDefault, - environments: unmarkedEnvironments, - resolveSandboxProviderDriver: readyDriverResolver(), - }); - expect(tenantManagedDefault.update).not.toHaveBeenCalled(); + fillInstanceDefault, + environments, + })).toBeNull(); + expect(fillInstanceDefault).not.toHaveBeenCalled(); + expect(environments.ensureManagedSandboxEnvironment).not.toHaveBeenCalled(); }); - it("never overrides a tenant-chosen custom default environment", async () => { - const instanceSettings = instanceSettingsSeam({ - get: vi.fn().mockResolvedValue({ defaultEnvironmentId: "env-ssh" }) as InstanceSettingsSeam["get"], - }); - const config = parsedConfig({ + it("keeps the environment ensured when filling the default fails", async () => { + const result = await applyManagedEnvironments(noDb, parsedConfig({ features: { enableManagedSandboxOnly: true }, environments: [{ name: "Daytona", provider: "daytona" }], - }); - - await applyManagedEnvironments(noDb, config, { + }), { env: {}, workerManager: runningWorkerManager(), - instanceSettings, - environments: environmentsSeam({ - getById: vi.fn().mockResolvedValue(environmentRow({ id: "env-ssh", driver: "ssh" })), - }), + fillInstanceDefault: vi.fn().mockRejectedValue(new Error("db exploded")), + environments: environmentsSeam(), resolveSandboxProviderDriver: readyDriverResolver(), }); - - expect(instanceSettings.update).not.toHaveBeenCalled(); + expect(result).toMatchObject({ ensured: 1, failed: 0 }); }); }); diff --git a/server/src/services/managed-environments.ts b/server/src/services/managed-environments.ts index febcbb03dbc..83e4df19405 100644 --- a/server/src/services/managed-environments.ts +++ b/server/src/services/managed-environments.ts @@ -57,7 +57,7 @@ import { HttpError } from "../errors.js"; import { logger } from "../middleware/logger.js"; import { environmentService } from "./environments.js"; import type { ManagedSandboxEnvironmentReconcileAction } from "./environments.js"; -import { instanceSettingsService } from "./instance-settings.js"; +import { fillUnsetInstanceDefault, type FillInstanceDefaultResult } from "./managed-instance-default.js"; import type { ManagedInstanceConfig } from "./managed-config.js"; import type { ManagedResourceStockStatus } from "./managed-resource-drift.js"; import { parseExecutionPolicyBootstrapEnv } from "./execution-policy-bootstrap.js"; @@ -143,14 +143,11 @@ export interface ApplyManagedEnvironmentsOptions { ReturnType, | "ensureManagedSandboxEnvironment" | "archiveManagedSandboxEnvironment" - | "getById" - | "update" - | "findManagedSandboxEnvironment" | "ensureManagedPluginEnvironment" | "archiveManagedPluginEnvironment" >; - /** Test seam: overrides the instance-settings service built from `db`. */ - instanceSettings?: Pick, "get" | "update">; + /** Test seam: overrides the locked fill of an unset instance default (`fillUnsetInstanceDefault`). */ + fillInstanceDefault?: (environmentId: string) => Promise; /** Test seam: overrides the sandbox-provider plugin driver lookup. */ resolveSandboxProviderDriver?: (input: { db: Db; @@ -249,44 +246,11 @@ export async function applyManagedEnvironments( ): Promise { if (!managedConfig) return null; - const settings = opts.instanceSettings ?? instanceSettingsService(db); + const fillInstanceDefault = opts.fillInstanceDefault + ?? ((environmentId: string) => fillUnsetInstanceDefault(db, environmentId)); const environments = opts.environments ?? environmentService(db); const managedSandboxOnlyDeclared = managedConfig.features.enableManagedSandboxOnly === true; - // Mode-off default cleanup runs BEFORE any ensure, and regardless of - // whether the document still declares environments or the provider is - // available: a reconciliation-stamped default must not outlive the mode - // through the paths that skip per-entry reconciliation entirely — the - // declaration was removed, the provider is down, or the row was - // archived. Only a default that points at the managed row AND carries - // the `managedDefaultStamped` marker reverts; tenant-chosen defaults - // (no marker) are untouched. - if (!managedSandboxOnlyDeclared) { - try { - const managedRow = await environments.findManagedSandboxEnvironment(undefined, { - includeArchived: true, - }); - if ( - managedRow && - managedRow.metadata?.managedDefaultStamped === true && - ((await settings.get()).defaultEnvironmentId ?? null) === managedRow.id - ) { - await settings.update({ defaultEnvironmentId: null }); - const { managedDefaultStamped: _cleared, ...remainingMetadata } = managedRow.metadata ?? {}; - await environments.update(managedRow.id, { metadata: remainingMetadata }); - logger.info( - { environmentId: managedRow.id }, - "instance default environment reverted from the managed sandbox environment (managed-sandbox-only is not declared)", - ); - } - } catch (err) { - logger.error( - { err }, - "failed to revert the stamped managed sandbox instance default (degraded: stale default until the next boot)", - ); - } - } - const pluginSpecs = managedConfig.pluginEnvironments; if (managedConfig.environments.length === 0 && pluginSpecs.length === 0) return null; @@ -330,53 +294,24 @@ export async function applyManagedEnvironments( const resolveDriver = opts.resolveSandboxProviderDriver ?? resolvePluginSandboxProviderDriverByKey; /** - * Point the instance default at the managed sandbox row when no - * deliberate choice stands in the way: an unset default, a default on - * the (hidden-under-this-mode) local row, or a dangling reference all - * move to the managed environment, so pickers and run selection agree - * that "the default" is the platform sandbox. A tenant-chosen custom - * environment (ssh, their own sandbox) is never overridden. + * Under `enableManagedSandboxOnly`, point the instance default at the + * managed sandbox row when nothing deliberate is set: an unset default, the + * (hidden-under-this-mode) local row, or a dangling reference. A set default + * (any other environment, whoever chose it) is never changed, and nothing + * reverts the filled default later: it is an ordinary set value from then + * on. The write is one locked transaction (`fillUnsetInstanceDefault`), so + * overlapping boots and admin writes cannot overwrite each other. * - * Gated symmetrically on the document declaring - * `enableManagedSandboxOnly`: - * - * - Declared: stamp the managed row as the default, so pickers and run - * selection agree the platform sandbox is "the default". The stamp is - * recorded as `managedDefaultStamped` in the row's platform-owned - * metadata (which boot reconciliation preserves and the client write - * floor protects), so a stamped default is distinguishable from one - * the tenant chose deliberately. - * - Not declared: local execution is a legitimate default again, so a - * default THIS reconciliation stamped earlier (points at the managed - * row AND carries the stamp marker) reverts to unset — otherwise - * turning the mode off would keep routing default-following runs - * into the sandbox off a stale stamp. A default the tenant selected - * themselves — the managed row without the marker, or any custom - * environment — is untouched in both directions. - * - * Idempotent and best-effort — a failure degrades to the run-time - * policy, which refuses local under managed-sandbox-only regardless. + * Best-effort: a failure degrades to the run-time policy, which refuses + * local under managed-sandbox-only regardless. */ - const ensureManagedInstanceDefault = async (managedEnvironment: { - id: string; - metadata: Record | null; - }): Promise => { + const ensureManagedInstanceDefault = async (managedEnvironment: { id: string }): Promise => { if (!managedSandboxOnlyDeclared) return; try { - const current = (await settings.get()).defaultEnvironmentId ?? null; - if (current === managedEnvironment.id) return; - if (current !== null) { - const currentEnvironment = await environments.getById(current); - if (currentEnvironment && currentEnvironment.driver !== "local") return; - } - // Marker first: a crash between the two writes must never leave a - // stamped default that the revert path cannot attribute. - await environments.update(managedEnvironment.id, { - metadata: { ...(managedEnvironment.metadata ?? {}), managedDefaultStamped: true }, - }); - await settings.update({ defaultEnvironmentId: managedEnvironment.id }); + const { filled, previousDefaultEnvironmentId } = await fillInstanceDefault(managedEnvironment.id); + if (!filled) return; logger.info( - { environmentId: managedEnvironment.id, previousDefaultEnvironmentId: current }, + { environmentId: managedEnvironment.id, previousDefaultEnvironmentId }, "instance default environment set to the managed sandbox environment", ); } catch (err) { diff --git a/server/src/services/managed-instance-default.ts b/server/src/services/managed-instance-default.ts new file mode 100644 index 00000000000..c08d1e6db89 --- /dev/null +++ b/server/src/services/managed-instance-default.ts @@ -0,0 +1,60 @@ +/** + * The managed instance default: one locked "fill if unset" write. + * + * On a managed instance, boot points the instance default at the managed + * sandbox when nothing deliberate is set: the default is unset, names the + * local environment, or names an environment that no longer exists. A set + * default (any other environment, whoever chose it) is never changed. + * + * The write locks the instance default row and rechecks it inside the same + * transaction, so two overlapping boots, or a boot overlapping an admin's + * default write, can never overwrite a value that another transaction just + * committed: whichever commits first wins, and the other sees a set default. + * + * Once filled, the default is an ordinary set value. Nothing reverts it + * automatically; an admin can change it in instance settings. + */ + +import { eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { environments, instanceSettings } from "@paperclipai/db"; +import { instanceSettingsService } from "./instance-settings.js"; + +/** Singleton key of the instance settings row that holds `defaultEnvironmentId`. */ +const INSTANCE_DEFAULT_SETTINGS_KEY = "default"; + +export interface FillInstanceDefaultResult { + filled: boolean; + previousDefaultEnvironmentId: string | null; +} + +/** + * Point the instance default at `environmentId` only if the current default + * is unset, the local environment, or an environment that no longer exists. + */ +export async function fillUnsetInstanceDefault(db: Db, environmentId: string): Promise { + return await db.transaction(async (tx) => { + const settings = instanceSettingsService(tx as unknown as Db); + // Creates the singleton row on a fresh instance, so the lock below always + // has a row to hold. + await settings.get(); + const [defaultRow] = await tx + .select({ defaultEnvironmentId: instanceSettings.defaultEnvironmentId }) + .from(instanceSettings) + .where(eq(instanceSettings.singletonKey, INSTANCE_DEFAULT_SETTINGS_KEY)) + .for("update"); + const current = defaultRow?.defaultEnvironmentId ?? null; + if (current === environmentId) return { filled: false, previousDefaultEnvironmentId: current }; + if (current !== null) { + const [currentEnvironment] = await tx + .select({ driver: environments.driver }) + .from(environments) + .where(eq(environments.id, current)); + if (currentEnvironment && currentEnvironment.driver !== "local") { + return { filled: false, previousDefaultEnvironmentId: current }; + } + } + await settings.update({ defaultEnvironmentId: environmentId }, { db: tx }); + return { filled: true, previousDefaultEnvironmentId: current }; + }); +}