mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
fix(server): reactivate drifted managed sandboxes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d6167516db
commit
8d061f180c
2 files changed
+148
-25
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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<ManagedSandboxEnvironmentReconcileResult> => {
|
||||
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),
|
||||
));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user