Files
PaperClipAI/server/src/services/managed-resource-drift.ts
T
DottaandPaperclip f6c6452b25 fix(server): preserve managed environment drift on boot (#10979)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Paperclip creates a managed sandbox environment for each company
during boot
> - Operators can change environment fields after Paperclip creates the
environment
> - The boot reconciler replaced those changes without checking for
drift
> - This pull request adds stock hashes and transactional drift
reconciliation
> - The benefit is that Paperclip can update untouched stock fields
without losing operator work

## Linked Issues or Issue Description

**What happened?**

The managed sandbox boot reconciler rewrote the stock description,
configuration, metadata, and status on every start. It did not detect
operator changes first. A restart could therefore remove an operator's
changes.

**Expected behavior**

Paperclip must preserve operator changes by default. It must update an
untouched stock environment when Paperclip ships new stock values. It
must perform each row update and stock-hash update atomically.

**Steps to reproduce**

1. Start Paperclip and let it create the managed sandbox environment.
2. Change one Paperclip-owned stock field on that environment.
3. Restart Paperclip.
4. Observe that the previous reconciler replaced the change.

**Paperclip version or commit**

This bug reproduces on `master` before this change.

**Deployment mode**

Local development and self-hosted server boot are affected.

## What Changed

- Add a shared deterministic stock-hash and drift classifier for
built-in resources.
- Track the managed sandbox stock hash with the company-scoped built-in
resource binding.
- Reconcile the environment and its stock metadata in one transaction
with a row lock.
- Preserve operator-modified and unmanaged rows and report their skipped
update state.
- Use archive ownership tokens so provider recovery reactivates only
Paperclip-archived rows and preserves later operator archive decisions.
- Keep operator-owned environment variables and unrelated metadata out
of the stock fingerprint.
- Add activity records for managed environment creation, updates,
skipped drift, tracking initialization, and archive changes.
- Add regression tests for current stock, available stock updates,
operator drift, unmanaged rows, archive and reactivation, user-owned
fields, and concurrent reconciliation.

## Verification

- `pnpm -r typecheck`
- `pnpm build`
- `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm test:run --
--mode serialized` (128 suites passed)
- Repository general server, UI, CLI, shared, skills catalog, database,
adapter, plugin SDK, and plugin creator projects passed. Two
embedded-Postgres tests exceeded the host's five-second default under
the aggregate run and passed in the complete database project with
`--testTimeout=20000`. One timing-sensitive sandbox stream test passed
on its focused retry.
- Focused managed-environment unit and integration coverage passed: 49
tests across the drift classifier, boot report, and environment service
suites.

## Risks

- The main risk is an incorrect ownership boundary in the stock
fingerprint. The fingerprint includes only Paperclip-owned stock fields.
Tests confirm that environment variables and unrelated metadata survive
reconciliation.
- Concurrent reconciliation could otherwise overwrite a late operator
edit. The implementation locks the environment row and updates the row
and hash binding in one transaction. A concurrency test covers this
path.
- There is no schema migration. Existing managed rows initialize
tracking without replacing their current values.

> 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

OpenAI GPT-5 Codex. The exact serving snapshot and context-window size
were not exposed. The model used reasoning, repository tools, code
execution, and test execution.

## 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-07 00:41:42 -05:00

37 lines
1.2 KiB
TypeScript

import { createHash } from "node:crypto";
export type ManagedResourceStockStatus =
| "missing"
| "stock_current"
| "stock_update_available"
| "operator_modified";
export function stableJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
export function stockHash(value: unknown) {
return `sha256:${createHash("sha256").update(stableJson(value)).digest("hex")}`;
}
export function resourceStatus(input: {
resourceId: string | null;
currentHash: string | null;
bindingStockHash: string | null;
latestStockHash: string;
}): ManagedResourceStockStatus {
if (!input.resourceId || !input.currentHash) return "missing";
if (input.currentHash === input.latestStockHash) return "stock_current";
if (input.bindingStockHash && input.currentHash === input.bindingStockHash) {
return "stock_update_available";
}
return "operator_modified";
}