Files
PaperClipAI/server/src/services/agent-instruction-revisions.ts
T
DottaandPaperclip 006d5691a6 feat(server): support volume-free web server handoff (#18518)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Its web server also owns controller admission and user commands.
> - A rolling deployment starts a replacement before the old server
exits.
> - Shared database state alone does not preserve local agent files,
browser chunks, or in-process commands.
> - This pull request adds an opt-in ephemeral server contract and a
local overlap test.
> - The benefit is a web server that can relinquish traffic and local
files without losing acknowledged work.

## Linked Issues or Issue Description

Refs #18403, #18327, #18335, #18341.

**Current behavior**

The control-plane web server still has local agent-file authority and
local command targets. A new container can miss saved bytes or send a
command to the wrong process. An existing browser tab also needs chunks
from its original build.

**Proposed behavior**

Eligible servers share only PostgreSQL, object storage and stable
secrets. A replacement waits for readiness. The old server drains
admitted requests, keeps owner commands available through handoff, and
releases controller leases. Explicit maintenance adopts existing files
before the volume is removed.

**Reason and benefit**

This is the application prerequisite for rolling app-only deployments.
Schema migrations and permanent multi-replica operation are outside this
change.

## What Changed

- Store agent-file manifests in PostgreSQL and immutable bytes in S3.
Treat local roots as disposable projections. Preserve existing
permission checks and edit conflicts.
- Add an explicit migration, eligibility and object-retention command to
the production runtime bundle.
- Add `/api/ready`, admitted-request drain, browser restart signaling
and retained immutable UI chunks.
- Carry transaction-aware work hints across controllers during handoff
and active execution. Keep listeners closed when idle.
- Deliver durable Stop and runtime responses to the execution owner.
Recheck request lifecycle and access before response dispatch.
- Reject local-only configuration in ephemeral mode. Keep the feature
disabled for existing installations by default.
- Add an isolated PostgreSQL/S3/proxy/browser deployment harness with
independent server homes and retained failure evidence.

## Verification

- All CI checks pass on `ce17b95019d3cf1cabd357d19b6d37f96782cc5c`:
workspace typecheck, build, Runner checks, server/general tests and
browser shards. Greptile reviewed the runtime changes at 5/5 with all
findings resolved; the final commit updates maps and the plan.
- Local targeted checks passed: coordinator 27; Stop/recovery 33;
PostgreSQL command delivery 6; route/workspace regressions 8; bootstrap
Stop 1; restart recovery 75. PostgreSQL command tests simulate the
provider edge.
- Local browser proof passed 91 reads and 19 saves; reconnect took
1,013ms. The tab opened Audit from S3 after deletion of A's whole
home/build. This uses compiled workspace code and local-trusted auth,
not released containers or live agents.
- Local workspace typecheck/build passed; current server and runtime
bundle passed. The earlier broad local suite was stopped after three
hours and is not a passing receipt. Its retained failures and isolated
reruns are documented in the plan.
- Stacked #18526 adds stricter graceful-exit proof, an unready
replacement control, live Runner qualification and a Stop handoff
correction. Hosted enablement remains gated on those results and Cloud
#1096. No hosted stack has been enabled.

## Risks

- Apply migration 0422 before enabling the feature. Existing data must
pass each owner's migration and eligibility checks. Do not discard a
volume based on a single migration page.
- Durable manifests remain authoritative after the opt-in is disabled.
Rolling back to a binary that ignores them is unsupported.
- Cold directory restoration and complete shutdown timing still need
qualification before enabling a hosted stack.
- Retained UI assets must cover the supported browser and rollback
window. Cleanup is explicit; no new idle maintenance polling is
introduced.
- This change does not claim production zero downtime or authorize a
fleet-wide enablement.

## Model Used

OpenAI Codex, GPT-6 family. The exact serving model ID and
context-window limit are not exposed in this session. Used reasoning,
code editing, shell execution and browser automation.

## 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-11-01 01:58:54 -06:00

434 lines
18 KiB
TypeScript

import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js";
import { createHash } from "node:crypto";
import { adoptAgentFiles, agentFileToken, isAgentFileSaveRecorded } from "./agent-file-store.js";
import { invalidateAgentFileProjection, persistDurableAgentFiles } from "./agent-file-durability.js";
import { and, desc, eq, getTableColumns, lt, or, sql } from "drizzle-orm";
import {
activityLog,
agents,
plugins,
pluginManagedResources,
agentInstructionHeads as heads,
agentInstructionRevisions as revisions,
fileStorageDb,
type Db,
} from "@paperclipai/db";
import type {
AgentInstructionCommitReceipt,
AgentInstructionDiff,
AgentInstructionHistory,
AgentInstructionSnapshot,
AgentInstructionSource,
} from "@paperclipai/shared";
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
import {
agentInstructionsBundleMode,
deriveBundleState,
resolveManagedInstructionsRoot,
} from "./agent-instructions.js";
import {
instructionBytes,
instructionPath,
assertInstructionPathSafe,
readInstructionBytes,
materializeInstructionBytes,
} from "./agent-instruction-files.js";
import {
authorizeInstructionCommit,
authorizeInstructionRead,
} from "./agent-instruction-authorization.js";
import type { AuthorizationActor } from "./authorization.js";
type Tx = Parameters<Parameters<Db["transaction"]>[0]>[0];
type Revision = typeof revisions.$inferSelect;
type PluginResetActor = { type: "plugin"; pluginId: string; pluginKey: string; agentKey: string };
type RevisionActor = AuthorizationActor | PluginResetActor;
const { contentBase64: _contentColumn, ...revisionMetadataColumns } =
getTableColumns(revisions);
export type InstructionTarget = { companyId: string; agentId: string };
export type InstructionCommitInput = InstructionTarget & {
entryFile: string;
content: string | Uint8Array;
baseRevisionId: string | null;
source: Exclude<AgentInstructionSource, "seed" | "restore">;
};
function metadata(row: Omit<Revision, "contentBase64">) {
const { createdAt, source, ...fields } = row;
return {
...fields,
source: source as AgentInstructionSource,
createdAt: createdAt.toISOString(),
};
}
function snapshot(row: Revision): AgentInstructionSnapshot {
const { contentBase64, ...fields } = row;
return {
revision: metadata(fields),
content: Buffer.from(contentBase64, "base64").toString("utf8"),
};
}
function owner(target: InstructionTarget, entryFile: string) {
return and(
eq(revisions.companyId, target.companyId),
eq(revisions.agentId, target.agentId),
eq(revisions.entryFile, entryFile),
);
}
function headOwner(target: InstructionTarget, entryFile: string) {
return and(
eq(heads.companyId, target.companyId),
eq(heads.agentId, target.agentId),
eq(heads.entryFile, entryFile),
);
}
export function agentInstructionRevisionService(db: Db) {
db = fileStorageDb(db);
async function lockTarget(
tx: Tx,
target: InstructionTarget,
actor?: AuthorizationActor,
) {
if (actor?.type === "agent" && actor.companyId !== target.companyId)
throw notFound("Agent not found");
const [agent] = await tx
.select()
.from(agents)
.where(
and(
eq(agents.id, target.agentId),
eq(agents.companyId, target.companyId),
),
)
.for("update");
if (!agent) throw notFound("Agent not found");
if (agentInstructionsBundleMode(agent) === "external") {
throw unprocessable(
"External host instructions do not support revision write-back. Ask an instance administrator to migrate this bundle to managed storage.",
{ code: "INSTRUCTION_MANAGED_BUNDLE_REQUIRED" },
);
}
const state = deriveBundleState(agent);
const entryFile = instructionPath(state.entryFile);
return { agent, entryFile, root: resolveManagedInstructionsRoot(agent) };
}
async function authorizeRead(
tx: Tx,
actor: AuthorizationActor,
target: InstructionTarget,
) {
return authorizeInstructionRead(tx, actor, { companyId: target.companyId, id: target.agentId });
}
// Trusted host-only reset authority. No HTTP or agent tool accepts this actor.
async function authorizePluginReset(tx: Tx, actor: PluginResetActor, target: InstructionTarget, agent: typeof agents.$inferSelect) {
const [plugin] = await tx.select().from(plugins).where(and(eq(plugins.id, actor.pluginId), eq(plugins.pluginKey, actor.pluginKey)));
const marker = agent.metadata?.paperclipManagedResource as Record<string, unknown> | undefined;
const [binding] = await tx.select().from(pluginManagedResources).where(and(
eq(pluginManagedResources.pluginId, actor.pluginId), eq(pluginManagedResources.pluginKey, actor.pluginKey),
eq(pluginManagedResources.companyId, target.companyId), eq(pluginManagedResources.resourceKind, "agent"),
eq(pluginManagedResources.resourceKey, actor.agentKey), eq(pluginManagedResources.resourceId, target.agentId),
));
if (!plugin || plugin.status !== "ready" || !plugin.manifestJson.capabilities.includes("agents.managed")
|| !plugin.manifestJson.agents?.some((declaration) => declaration.agentKey === actor.agentKey)
|| !binding || marker?.pluginId !== actor.pluginId || marker.pluginKey !== actor.pluginKey
|| marker.resourceKind !== "agent" || marker.resourceKey !== actor.agentKey) {
throw forbidden("Plugin reset is limited to its declared, bound managed agent");
}
const declared = plugin.manifestJson.agents!.find((declaration) => declaration.agentKey === actor.agentKey)!;
return { ...actor, declaredEntryFile: instructionPath(declared.instructions?.entryFile ?? "AGENTS.md") };
}
async function readForPluginReset(target: InstructionTarget, actor: PluginResetActor) {
return db.transaction(async (tx) => {
const state = await lockTarget(tx, target);
const bound = await authorizePluginReset(tx, actor, target, state.agent);
// Preserve the formerly configured entry before an explicit stock reset.
await currentFile(tx, target, state, bound);
const row = await currentFile(tx, target, { ...state, entryFile: bound.declaredEntryFile }, bound);
return { snapshot: row ? snapshot(row) : null, configuredEntryFile: state.entryFile };
});
}
async function head(tx: Tx, target: InstructionTarget, entryFile: string) {
const [result] = await tx
.select({ revision: revisions })
.from(heads)
.innerJoin(revisions, eq(revisions.id, heads.revisionId))
.where(headOwner(target, entryFile));
return result?.revision ?? null;
}
// The deployed revision tables are read-only upgrade input. Current bytes
// live in the agent directory; old UUID clients receive a content ETag.
async function currentFile(tx: Tx, target: InstructionTarget, state: Awaited<ReturnType<typeof lockTarget>>, actor?: RevisionActor) {
await adoptAgentFiles(tx, state.agent);
const bytes = await readInstructionBytes(state.root, state.entryFile);
if (bytes === null) return null;
return currentRow(target, state.entryFile, bytes, actor);
}
function currentRow(target: InstructionTarget, entryFile: string, bytes: Buffer, actor?: RevisionActor): Revision {
return { ...target, id: agentFileToken(bytes), entryFile, contentBase64: bytes.toString("base64"),
contentHash: createHash("sha256").update(bytes).digest("hex"), byteLength: bytes.length,
parentRevisionId: null, baseRevisionId: null, restoredFromRevisionId: null, source: "api",
actorAgentId: actor?.type === "agent" ? actor.agentId ?? null : null,
actorUserId: actor?.type === "board" ? actor.userId ?? null : null,
responsibleUserId: actor?.type === "board" ? actor.userId ?? null : actor?.type === "agent" ? actor.onBehalfOfUserId ?? null : null,
sourceRunId: actor && actor.type !== "plugin" ? actor.runId ?? null : null, createdAt: new Date(0) };
}
async function getRevision(
tx: Tx,
target: InstructionTarget,
entryFile: string,
id: string,
) {
const [row] = await tx
.select()
.from(revisions)
.where(and(owner(target, entryFile), eq(revisions.id, id)));
if (!row) throw notFound("Instruction revision not found");
return row;
}
async function readCurrent(
target: InstructionTarget,
actor: AuthorizationActor,
): Promise<AgentInstructionSnapshot | null> {
return db.transaction(async (tx) => {
const state = await lockTarget(tx, target, actor);
const bound = await authorizeRead(tx, actor, target);
const row = await currentFile(tx, target, state, bound);
return row ? snapshot(row) : null;
});
}
/** Trusted orchestration read for the already authorized run target. This
* never seeds or writes, and is not exposed through HTTP or model tools. */
async function readCommittedForRuntime(target: InstructionTarget): Promise<AgentInstructionSnapshot | null> {
return db.transaction(async (tx) => {
const state = await lockTarget(tx, target);
const current = await currentFile(tx, target, state);
return current ? snapshot(current) : null;
});
}
/** Rebuild disk from the current committed head under the same lock as commits. Safe after restart. */
async function materializeCurrent(target: InstructionTarget): Promise<void> {
await db.transaction(async (tx) => {
const state = await lockTarget(tx, target);
await adoptAgentFiles(tx, state.agent);
});
}
async function commitInternal(
input:
| InstructionCommitInput
| (Omit<InstructionCommitInput, "content" | "source"> & {
restoreRevisionId: string;
}),
actor: RevisionActor,
configuredEntryFile?: string,
): Promise<AgentInstructionCommitReceipt> {
instructionPath(input.entryFile);
const bytes = "content" in input ? instructionBytes(input.content) : null;
const result = await db.transaction(async (tx) => {
const state = await lockTarget(tx, input, actor.type === "plugin" ? undefined : actor);
const bound = actor.type === "plugin"
? await authorizePluginReset(tx, actor, input, state.agent)
: await authorizeInstructionCommit(tx, actor, state.agent);
if (bound.type === "plugin") {
if (input.entryFile !== bound.declaredEntryFile) throw forbidden("Plugin reset must restore its declared entry file");
if (configuredEntryFile !== state.entryFile) throw conflict("Configured instruction entry changed; read the current entry and retry", { code: "INSTRUCTION_ENTRY_CHANGED", entryFile: state.entryFile });
state.entryFile = input.entryFile;
}
if (state.entryFile !== input.entryFile)
throw conflict(
"Configured instruction entry changed; read the current entry and retry",
{ code: "INSTRUCTION_ENTRY_CHANGED", entryFile: state.entryFile },
);
await assertInstructionPathSafe(state.root, state.entryFile);
const current = await currentFile(tx, input, state, bound);
const restored =
"restoreRevisionId" in input
? await getRevision(
tx,
input,
input.entryFile,
input.restoreRevisionId,
)
: null;
const candidate = bytes ?? Buffer.from(restored!.contentBase64, "base64");
const recordSave = (row: Revision) => tx.insert(activityLog).values({ companyId: input.companyId,
actorType: bound.type === "plugin" ? "plugin" : bound.type === "board" ? "user" : "agent",
actorId: (bound.type === "plugin" ? bound.pluginId : bound.type === "board" ? bound.userId : bound.agentId)!,
agentId: bound.type === "agent" ? bound.agentId : null, runId: row.sourceRunId,
responsibleUserId: row.responsibleUserId, action: "agent.files_updated", entityType: "agent", entityId: input.agentId,
details: { path: input.entryFile, contentHash: row.contentHash, source: restored ? "legacy_restore" : (input as InstructionCommitInput).source } });
const bindEntry = async () => {
if (
!state.agent.adapterConfig.instructionsRootPath ||
!state.agent.adapterConfig.instructionsBundleMode ||
(bound.type === "plugin" && configuredEntryFile !== input.entryFile)
) {
await updateAgentConfigurationInTransaction(tx as unknown as Db, state.agent.id, {
adapterConfig: {
...state.agent.adapterConfig,
instructionsBundleMode: "managed",
instructionsRootPath: state.root,
instructionsEntryFile: state.entryFile,
instructionsFilePath: `${state.root}/${state.entryFile}`,
},
}, { allowPendingApprovalConfigUpdate: true });
}
};
// An exact replay can safely return the durable receipt even after its base
// advanced. Restore also deduplicates identical content, preserving history.
if (current && current.contentBase64 === candidate.toString("base64")) {
if (!await isAgentFileSaveRecorded(tx, state.agent, input.entryFile, current.contentHash)) await recordSave(current);
await bindEntry();
return { row: current, changed: false };
}
// A pre-upgrade run can still pin a historical UUID. Compare its bytes,
// never let the old head project over a newer directory.
const legacyBase = input.baseRevisionId && current?.id !== input.baseRevisionId
? await tx.select().from(revisions).where(and(owner(input, input.entryFile), eq(revisions.id, input.baseRevisionId))).then(rows => rows[0])
: null;
if ((current?.id ?? null) !== input.baseRevisionId && !(legacyBase && legacyBase.contentHash === current?.contentHash)) {
throw conflict(
"Instructions changed since the base revision. Read the current entry before saving.",
{
code: "INSTRUCTION_REVISION_CONFLICT",
baseRevisionId: input.baseRevisionId,
currentRevisionId: current?.id ?? null,
},
);
}
await invalidateAgentFileProjection(state.root);
await materializeInstructionBytes(state.root, state.entryFile, candidate);
await persistDurableAgentFiles(tx, state.agent, state.root);
const row = currentRow(input, input.entryFile, candidate, bound);
await recordSave(row);
await bindEntry();
return { row, changed: true };
});
return { ...snapshot(result.row), changed: result.changed, materialization: "current" };
}
async function history(
target: InstructionTarget & {
entryFile: string;
cursor?: string;
limit?: number;
},
actor: AuthorizationActor,
): Promise<AgentInstructionHistory> {
const limit = Math.max(1, Math.min(100, target.limit ?? 50));
return db.transaction(async (tx) => {
const state = await lockTarget(tx, target, actor);
const bound = await authorizeRead(tx, actor, target);
await currentFile(tx, target, state, bound);
instructionPath(target.entryFile);
const cursor = target.cursor
? await getRevision(tx, target, target.entryFile, target.cursor)
: null;
// Keep PostgreSQL timestamp precision; JS Date truncates microseconds and
// would skip revisions in the cursor's fractional millisecond.
const cursorTime = cursor
? sql`(select created_at from agent_instruction_revisions where id = ${cursor.id})`
: null;
const rows = await tx
.select(revisionMetadataColumns)
.from(revisions)
.where(
and(
owner(target, target.entryFile),
cursor && cursorTime
? or(
lt(revisions.createdAt, cursorTime),
and(
eq(revisions.createdAt, cursorTime),
lt(revisions.id, cursor.id),
),
)
: undefined,
),
)
.orderBy(desc(revisions.createdAt), desc(revisions.id))
.limit(limit + 1);
return {
revisions: rows.slice(0, limit).map(metadata),
nextCursor: rows.length > limit ? rows[limit - 1]!.id : null,
};
});
}
async function readRevision(
target: InstructionTarget & { entryFile: string; revisionId: string },
actor: AuthorizationActor,
) {
return db.transaction(async (tx) => {
await lockTarget(tx, target, actor);
await authorizeRead(tx, actor, target);
return snapshot(
await getRevision(
tx,
target,
instructionPath(target.entryFile),
target.revisionId,
),
);
});
}
async function diff(
target: InstructionTarget & {
entryFile: string;
fromRevisionId: string;
toRevisionId: string;
},
actor: AuthorizationActor,
): Promise<AgentInstructionDiff> {
const from = await readRevision(
{ ...target, revisionId: target.fromRevisionId },
actor,
);
const to = await readRevision(
{ ...target, revisionId: target.toRevisionId },
actor,
);
// Linear, bounded exact replacement diff. Code points keep surrogate pairs intact.
const a = Array.from(from.content),
b = Array.from(to.content);
let start = 0,
end = 0;
while (start < a.length && start < b.length && a[start] === b[start])
start++;
while (
end < a.length - start &&
end < b.length - start &&
a[a.length - end - 1] === b[b.length - end - 1]
)
end++;
return {
from,
to,
prefix: a.slice(0, start).join(""),
removed: a.slice(start, a.length - end).join(""),
added: b.slice(start, b.length - end).join(""),
suffix: end ? a.slice(-end).join("") : "",
};
}
return {
readCommittedForRuntime,
readCurrent,
readForPluginReset,
commitPluginReset: (input: Omit<InstructionCommitInput, "source"> & { configuredEntryFile: string }, actor: PluginResetActor) =>
commitInternal({ ...input, source: "api" }, actor, input.configuredEntryFile),
readRevision,
history,
diff,
materializeCurrent,
commit: (input: InstructionCommitInput, actor: AuthorizationActor) =>
commitInternal(input, actor),
restore: (
input: InstructionTarget & {
entryFile: string;
baseRevisionId: string;
revisionId: string;
},
actor: AuthorizationActor,
) =>
commitInternal({ ...input, restoreRevisionId: input.revisionId }, actor),
};
}