diff --git a/server/src/__tests__/environment-service.test.ts b/server/src/__tests__/environment-service.test.ts index e0bd5a09c8f..2e4c9a36c55 100644 --- a/server/src/__tests__/environment-service.test.ts +++ b/server/src/__tests__/environment-service.test.ts @@ -973,6 +973,93 @@ describeEmbeddedPostgres("environmentService leases", () => { expect(restored.status).toBe("active"); }); + it("reactivates a reconciler-archived row without replacing operator drift", async () => { + const companyId = await seedCompany(); + const created = await svc.ensureManagedSandboxEnvironment({ + companyId, + name: "Daytona", + description: "Managed stock", + provider: "daytona", + config: { target: "us" }, + stockVersion: "v1", + }); + await db + .update(environments) + .set({ + description: "Operator description", + config: { provider: "daytona", target: "operator" }, + }) + .where(eq(environments.id, created.environment.id)); + + expect((await svc.archiveManagedSandboxEnvironment({ provider: "daytona" }))?.status) + .toBe("archived"); + const [archivedBinding] = await db + .select() + .from(builtInManagedResources) + .where(eq(builtInManagedResources.companyId, companyId)); + expect(archivedBinding?.defaultsJson).toMatchObject({ + description: "Managed stock", + status: "archived", + }); + + const restored = await svc.ensureManagedSandboxEnvironment({ + companyId, + name: "Daytona v2", + description: "Managed stock v2", + provider: "daytona", + config: { target: "eu" }, + stockVersion: "v2", + }); + expect(restored).toMatchObject({ + action: "skipped", + stockStatus: "operator_modified", + updateAvailable: true, + environment: { + status: "active", + name: "Daytona", + description: "Operator description", + config: { provider: "daytona", target: "operator" }, + }, + }); + const [reactivatedBinding] = await db + .select() + .from(builtInManagedResources) + .where(eq(builtInManagedResources.companyId, companyId)); + expect(reactivatedBinding?.defaultsJson).toMatchObject({ + description: "Managed stock", + status: "active", + }); + expect(reactivatedBinding?.stockVersion).toBe("v1"); + expect(restored.stockHash).not.toBe(reactivatedBinding?.stockHash); + }); + + it("preserves an operator-archived managed row", async () => { + const companyId = await seedCompany(); + const created = await svc.ensureManagedSandboxEnvironment({ + companyId, + name: "Daytona", + provider: "daytona", + config: { target: "us" }, + }); + await db + .update(environments) + .set({ status: "archived" }) + .where(eq(environments.id, created.environment.id)); + + const reconciled = await svc.ensureManagedSandboxEnvironment({ + companyId, + name: "Daytona", + provider: "daytona", + config: { target: "us" }, + }); + expect(reconciled).toMatchObject({ + action: "skipped", + stockStatus: "operator_modified", + updateAvailable: true, + environment: { status: "archived" }, + }); + }); + it("preserves an existing unmanaged sandbox row holding the desired name", async () => { const companyId = await seedCompany(); const handMade = await svc.create({ diff --git a/server/src/services/environments.ts b/server/src/services/environments.ts index 3930e8f07a3..e1575569ed0 100644 --- a/server/src/services/environments.ts +++ b/server/src/services/environments.ts @@ -320,6 +320,7 @@ export function environmentService(db: Db) { let activityCompanyIds: string[] = []; let trackingInitialized = false; + let providerReactivated = false; const reconciliation = await db.transaction( async (tx): Promise => { const companyIds = input.companyId @@ -510,11 +511,59 @@ export function environmentService(db: Db) { if (stockStatus === "operator_modified") { const baseline = matchingBindings[0]; + let baselineDefaults = baseline?.defaultsJson ?? desiredStock; + let baselineHash = baseline?.stockHash ?? latestStockHash; + + // Provider unavailability is an operational state transition, not + // an operator edit. If the binding records that Paperclip archived + // this row, restore only its availability status. Keep every other + // operator-modified field intact and leave the stock update pending. + // A manually archived row still has an active binding baseline, so + // it remains operator_modified and is not reactivated here. + const archivedByReconciler = row.status === "archived" && matchingBindings.some( + (binding) => (cloneRecord(binding.defaultsJson) ?? {}).status === "archived", + ); + if (archivedByReconciler) { + const reactivated = await tx + .update(environments) + .set({ status: "active", updatedAt: new Date() }) + .where(and(eq(environments.id, row.id), eq(environments.status, "archived"))) + .returning() + .then((rows) => rows[0] ?? null); + if (!reactivated) { + throw new Error("Managed sandbox environment changed during reactivation"); + } + row = reactivated; + providerReactivated = true; + + for (const binding of matchingBindings) { + const reactivatedDefaults = { + ...cloneRecord(binding.defaultsJson), + status: "active", + }; + const reactivatedHash = stockHash(reactivatedDefaults); + await tx + .update(builtInManagedResources) + .set({ + stockHash: reactivatedHash, + defaultsJson: reactivatedDefaults, + updatedAt: new Date(), + }) + .where(and( + eq(builtInManagedResources.id, binding.id), + eq(builtInManagedResources.resourceId, row.id), + )); + if (binding.id === baseline?.id) { + baselineDefaults = reactivatedDefaults; + baselineHash = reactivatedHash; + } + } + } await writeBindings( row.id, baseline?.stockVersion ?? input.stockVersion ?? MANAGED_ENVIRONMENT_STOCK_VERSION, - baseline?.stockHash ?? latestStockHash, - baseline?.defaultsJson ?? desiredStock, + baselineHash, + baselineDefaults, false, ); return { @@ -594,6 +643,7 @@ export function environmentService(db: Db) { stockStatus: reconciliation.stockStatus, updateAvailable: reconciliation.updateAvailable, stockHash: reconciliation.stockHash, + providerReactivated, }, }))); } @@ -651,17 +701,6 @@ export function environmentService(db: Db) { eq(builtInManagedResources.resourceId, existing.id), )) : []; - const keys = managedMetadataKeys({}, bindings); - const currentStock = managedEnvironmentStock({ - name: existing.name, - description: existing.description ?? null, - config: existing.config, - metadata: existing.metadata, - status: existing.status, - }, keys); - const currentHash = stockHash(currentStock); - const stockControlled = bindings.some((binding) => binding.stockHash === currentHash); - const archived = await tx .update(environments) .set({ status: "archived", updatedAt: new Date() }) @@ -670,17 +709,14 @@ export function environmentService(db: Db) { .then((rows) => rows[0] ?? null); if (!archived) return null; - // Archival is a Paperclip-owned availability transition. Advance the - // installed-stock baseline only when the row was stock-controlled; - // operator-modified fields must never be laundered into stock. - if (stockControlled && bindings.length > 0) { - const archivedStock = managedEnvironmentStock({ - name: archived.name, - description: archived.description ?? null, - config: archived.config, - metadata: archived.metadata, - status: archived.status, - }, keys); + // Archival is a Paperclip-owned availability transition. Record only + // that status change in each installed baseline. Deriving the new hash + // from defaultsJson keeps operator-modified row fields out of stock. + for (const binding of bindings) { + const archivedStock = { + ...cloneRecord(binding.defaultsJson), + status: "archived", + }; await tx .update(builtInManagedResources) .set({ @@ -689,7 +725,7 @@ export function environmentService(db: Db) { updatedAt: new Date(), }) .where(and( - inArray(builtInManagedResources.id, bindings.map((binding) => binding.id)), + eq(builtInManagedResources.id, binding.id), eq(builtInManagedResources.resourceId, existing.id), )); }