From baf8f1dbe90d7d551f3b263a1d85e1e2404ab8ce Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 6 Aug 2026 14:48:51 +0000 Subject: [PATCH] fix(server): preserve post-outage archive decisions Co-Authored-By: Paperclip --- .../src/__tests__/environment-service.test.ts | 10 +-- server/src/services/environments.ts | 89 ++++++++++++++++--- 2 files changed, 82 insertions(+), 17 deletions(-) diff --git a/server/src/__tests__/environment-service.test.ts b/server/src/__tests__/environment-service.test.ts index 2e4c9a36c55..9e81c9cf700 100644 --- a/server/src/__tests__/environment-service.test.ts +++ b/server/src/__tests__/environment-service.test.ts @@ -1033,7 +1033,7 @@ describeEmbeddedPostgres("environmentService leases", () => { expect(restored.stockHash).not.toBe(reactivatedBinding?.stockHash); }); - it("preserves an operator-archived managed row", async () => { + it("preserves an operator archive decision made after provider archival", async () => { const companyId = await seedCompany(); const created = await svc.ensureManagedSandboxEnvironment({ companyId, @@ -1041,10 +1041,10 @@ describeEmbeddedPostgres("environmentService leases", () => { provider: "daytona", config: { target: "us" }, }); - await db - .update(environments) - .set({ status: "archived" }) - .where(eq(environments.id, created.environment.id)); + expect((await svc.archiveManagedSandboxEnvironment({ provider: "daytona" }))?.status) + .toBe("archived"); + expect((await svc.update(created.environment.id, { status: "archived" }))?.status) + .toBe("archived"); const reconciled = await svc.ensureManagedSandboxEnvironment({ companyId, diff --git a/server/src/services/environments.ts b/server/src/services/environments.ts index e1575569ed0..657d366a024 100644 --- a/server/src/services/environments.ts +++ b/server/src/services/environments.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { and, desc, eq, inArray, ne, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { @@ -236,6 +237,25 @@ const MANAGED_ENVIRONMENT_BUNDLE_KEY = "managed-sandbox-environment"; const MANAGED_ENVIRONMENT_RESOURCE_KIND = "environment"; const MANAGED_ENVIRONMENT_RESOURCE_KEY = "managed-sandbox"; const MANAGED_ENVIRONMENT_STOCK_VERSION = "managed-environment-v1"; +const MANAGED_ENVIRONMENT_ARCHIVE_TOKEN_METADATA_KEY = "_paperclipManagedArchiveToken"; +const MANAGED_ENVIRONMENT_ARCHIVE_TOKEN_DEFAULTS_KEY = "_paperclipManagedArchiveToken"; + +function managedEnvironmentBaselineDefaults( + defaultsJson: Record, +): Record { + const baseline = { ...defaultsJson }; + delete baseline[MANAGED_ENVIRONMENT_ARCHIVE_TOKEN_DEFAULTS_KEY]; + return baseline; +} + +function withoutManagedEnvironmentArchiveToken( + metadata: Record | null, +): Record | null { + if (!metadata) return null; + const next = { ...metadata }; + delete next[MANAGED_ENVIRONMENT_ARCHIVE_TOKEN_METADATA_KEY]; + return next; +} function managedMetadataKeys( desiredMetadata: Record, @@ -491,6 +511,8 @@ export function environmentService(db: Db) { }, keys); const currentHash = stockHash(currentStock); const matchingBindings = bindings.filter((binding) => binding.resourceId === row!.id); + const rowArchiveToken = (row.metadata as Record | null) + ?.[MANAGED_ENVIRONMENT_ARCHIVE_TOKEN_METADATA_KEY]; let stockStatus = resourceStatus({ resourceId: row.id, currentHash, @@ -508,10 +530,21 @@ export function environmentService(db: Db) { ); if (stockControlledBinding) stockStatus = "stock_update_available"; } + const operatorReaffirmedArchive = row.status === "archived" && matchingBindings.some( + (binding) => { + const bindingToken = binding.defaultsJson[MANAGED_ENVIRONMENT_ARCHIVE_TOKEN_DEFAULTS_KEY]; + return binding.defaultsJson.status === "archived" && + typeof bindingToken === "string" && + bindingToken !== rowArchiveToken; + }, + ); + if (operatorReaffirmedArchive) stockStatus = "operator_modified"; if (stockStatus === "operator_modified") { const baseline = matchingBindings[0]; - let baselineDefaults = baseline?.defaultsJson ?? desiredStock; + let baselineDefaults = baseline + ? managedEnvironmentBaselineDefaults(baseline.defaultsJson) + : desiredStock; let baselineHash = baseline?.stockHash ?? latestStockHash; // Provider unavailability is an operational state transition, not @@ -520,13 +553,21 @@ export function environmentService(db: Db) { // 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", - ); + const archivedByReconciler = row.status === "archived" && + typeof rowArchiveToken === "string" && + matchingBindings.some((binding) => { + const bindingDefaults = binding.defaultsJson; + return bindingDefaults.status === "archived" && + bindingDefaults[MANAGED_ENVIRONMENT_ARCHIVE_TOKEN_DEFAULTS_KEY] === rowArchiveToken; + }); if (archivedByReconciler) { const reactivated = await tx .update(environments) - .set({ status: "active", updatedAt: new Date() }) + .set({ + status: "active", + metadata: withoutManagedEnvironmentArchiveToken(row.metadata), + updatedAt: new Date(), + }) .where(and(eq(environments.id, row.id), eq(environments.status, "archived"))) .returning() .then((rows) => rows[0] ?? null); @@ -538,7 +579,7 @@ export function environmentService(db: Db) { for (const binding of matchingBindings) { const reactivatedDefaults = { - ...cloneRecord(binding.defaultsJson), + ...managedEnvironmentBaselineDefaults(binding.defaultsJson), status: "active", }; const reactivatedHash = stockHash(reactivatedDefaults); @@ -598,7 +639,9 @@ export function environmentService(db: Db) { name: desiredName, description: input.description ?? null, config: desiredConfig, - metadata: mergeManagedEnvironmentMetadata(row.metadata, desiredMetadata, keys), + metadata: withoutManagedEnvironmentArchiveToken( + mergeManagedEnvironmentMetadata(row.metadata, desiredMetadata, keys), + ), status: "active", updatedAt: new Date(), }) @@ -701,9 +744,18 @@ export function environmentService(db: Db) { eq(builtInManagedResources.resourceId, existing.id), )) : []; + const archiveToken = randomUUID(); + const archivedMetadata = { + ...(existing.metadata ?? {}), + [MANAGED_ENVIRONMENT_ARCHIVE_TOKEN_METADATA_KEY]: archiveToken, + }; const archived = await tx .update(environments) - .set({ status: "archived", updatedAt: new Date() }) + .set({ + status: "archived", + metadata: archivedMetadata, + updatedAt: new Date(), + }) .where(and(eq(environments.id, existing.id), eq(environments.status, "active"))) .returning() .then((rows) => rows[0] ?? null); @@ -714,14 +766,17 @@ export function environmentService(db: Db) { // from defaultsJson keeps operator-modified row fields out of stock. for (const binding of bindings) { const archivedStock = { - ...cloneRecord(binding.defaultsJson), + ...managedEnvironmentBaselineDefaults(binding.defaultsJson), status: "archived", }; await tx .update(builtInManagedResources) .set({ stockHash: stockHash(archivedStock), - defaultsJson: archivedStock, + defaultsJson: { + ...archivedStock, + [MANAGED_ENVIRONMENT_ARCHIVE_TOKEN_DEFAULTS_KEY]: archiveToken, + }, updatedAt: new Date(), }) .where(and( @@ -940,6 +995,7 @@ export function environmentService(db: Db) { patch: UpdateEnvironment, options?: { db?: EnvironmentWriteDb }, ): Promise => { + const writeDb = options?.db ?? db; const values: Partial = { updatedAt: new Date(), }; @@ -951,9 +1007,18 @@ export function environmentService(db: Db) { if ("envVars" in patch && patch.envVars !== undefined) { values.envVars = (patch.envVars ?? {}) as Record; } - if (patch.metadata !== undefined) values.metadata = patch.metadata ?? null; + if (patch.metadata !== undefined) { + values.metadata = withoutManagedEnvironmentArchiveToken(patch.metadata ?? null); + } else if (patch.status !== undefined) { + const existingMetadata = await writeDb + .select({ metadata: environments.metadata }) + .from(environments) + .where(eq(environments.id, id)) + .then((rows) => rows[0]?.metadata ?? null); + values.metadata = withoutManagedEnvironmentArchiveToken(existingMetadata); + } - const row = await (options?.db ?? db) + const row = await writeDb .update(environments) .set(values) .where(eq(environments.id, id))