mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:17:33 +01:00
## 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>
142 lines
8.6 KiB
TypeScript
142 lines
8.6 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { and, eq, gt, inArray, sql } from "drizzle-orm";
|
|
import { heartbeatRuns, nativeRunFinalizations, type Db } from "@paperclipai/db";
|
|
import type { HarnessRuntimeRequestResolution } from "../../vendor/paperclip-runner/index.js";
|
|
import { accessService } from "../access.js";
|
|
import { loadConfig } from "../../config.js";
|
|
import { conflict } from "../../errors.js";
|
|
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../delivery-work-notifications.js";
|
|
import type { createDeliveryWorkCoordinator } from "../delivery-work-coordinator.js";
|
|
import { activeNativeExecutionRunIds, subscribeActiveNativeExecutions } from "./native-active-runs.js";
|
|
import { currentNativeControllerIdentity } from "./native-restart-recovery.js";
|
|
import { assertNativeRuntimeRequestResolverAuthorized, NativeRuntimeRequestResolutionAuthorizationError,
|
|
readPendingNativeRuntimeRequest, type NativeRuntimeRequestResolver, type PendingNativeRuntimeRequest } from "./runtime-request-resolution-authority.js";
|
|
|
|
type ResponseCommand = {
|
|
commandId: string;
|
|
request: PendingNativeRuntimeRequest;
|
|
actor: NativeRuntimeRequestResolver;
|
|
localImplicit: boolean;
|
|
resolution: HarnessRuntimeRequestResolution;
|
|
fingerprint: string;
|
|
state: "pending" | "queued" | "obsolete" | "denied";
|
|
};
|
|
function commands(result: Record<string, unknown> | null): Record<string, ResponseCommand> {
|
|
const value = result?.nativeRuntimeResponses;
|
|
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, ResponseCommand> : {};
|
|
}
|
|
const requestKey = (id: string) => createHash("sha256").update(id).digest("hex");
|
|
|
|
/** The run owns these commands just as it owns Stop intent. HTTP admission is
|
|
* durable; the controller lease and canonical request lifecycle own dispatch. */
|
|
export async function enqueueNativeRuntimeResponse(db: Db, input: {
|
|
request: PendingNativeRuntimeRequest;
|
|
actor: NativeRuntimeRequestResolver;
|
|
localImplicit: boolean;
|
|
resolution: HarnessRuntimeRequestResolution;
|
|
}) {
|
|
assertNativeRuntimeRequestResolverAuthorized(input.request, input.actor);
|
|
const key = requestKey(input.request.requestId);
|
|
const fingerprint = JSON.stringify({ request: input.request, userId: input.actor.userId, resolution: input.resolution });
|
|
return db.transaction(async tx => {
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.nativeRuntimeResponses);
|
|
const [run] = await tx.select().from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.id, input.request.runId), eq(heartbeatRuns.companyId, input.request.companyId),
|
|
)).for("update").limit(1);
|
|
if (!run || run.runtimeMode !== "native" || run.status !== "running") throw conflict("This runner session is no longer accepting runtime responses.");
|
|
const existing = commands(run.resultJson);
|
|
const prior = existing[key];
|
|
if (prior) {
|
|
if (prior.fingerprint !== fingerprint) throw conflict("A different response was already submitted for this runtime request.");
|
|
if (prior.state === "denied" || prior.state === "obsolete") throw conflict("This runtime request is no longer actionable.");
|
|
return { commandId: prior.commandId };
|
|
}
|
|
const pending = await readPendingNativeRuntimeRequest(tx as unknown as Db, input.request);
|
|
if (!pending || pending.turnId !== input.request.turnId || pending.requestKind !== input.request.requestKind) {
|
|
throw conflict("This runtime request is stale or is no longer pending.");
|
|
}
|
|
assertNativeRuntimeRequestResolverAuthorized(pending, input.actor);
|
|
const command: ResponseCommand = { ...input, request: pending, commandId: `native-runtime-response:${randomUUID()}`, fingerprint, state: "pending" };
|
|
await tx.update(heartbeatRuns).set({ resultJson: { ...run.resultJson, nativeRuntimeResponses: { ...existing, [key]: command } },
|
|
updatedAt: new Date() }).where(eq(heartbeatRuns.id, run.id));
|
|
return { commandId: command.commandId };
|
|
});
|
|
}
|
|
|
|
/** Recheck access at consumption, including revocation during controller
|
|
* replacement. Only the configured local-trusted operator bypasses membership. */
|
|
async function authorize(db: Db, command: ResponseCommand) {
|
|
const pending = await readPendingNativeRuntimeRequest(db, command.request);
|
|
if (!pending || pending.turnId !== command.request.turnId || pending.requestKind !== command.request.requestKind) {
|
|
throw new RuntimeResponseObsoleteError();
|
|
}
|
|
const access = accessService(db);
|
|
const local = command.localImplicit && loadConfig().deploymentMode === "local_trusted";
|
|
const admin = local || await access.isInstanceAdmin(command.actor.userId);
|
|
if (!local) {
|
|
const membership = await access.getMembership(command.request.companyId, "user", command.actor.userId);
|
|
if (membership?.status !== "active" || (!admin && membership.membershipRole === "viewer")) {
|
|
throw new NativeRuntimeRequestResolutionAuthorizationError("native_runtime_request_resolver_denied");
|
|
}
|
|
}
|
|
assertNativeRuntimeRequestResolverAuthorized(pending, { ...command.actor, isInstanceAdmin: admin });
|
|
return { pending, actor: { ...command.actor, isInstanceAdmin: admin } };
|
|
}
|
|
class RuntimeResponseObsoleteError extends Error {}
|
|
|
|
export function registerNativeRuntimeResponseDelivery(db: Db, coordinator: ReturnType<typeof createDeliveryWorkCoordinator>) {
|
|
const worker = coordinator.register(DELIVERY_QUEUES.nativeRuntimeResponses, {
|
|
retryMs: 1000, runDuringDrain: true,
|
|
async run(signal) {
|
|
const ids = [...activeNativeExecutionRunIds];
|
|
if (!ids.length) return;
|
|
const identity = await currentNativeControllerIdentity();
|
|
const rows = await db.select({ id: heartbeatRuns.id, result: heartbeatRuns.resultJson }).from(heartbeatRuns)
|
|
.innerJoin(nativeRunFinalizations, eq(nativeRunFinalizations.runId, heartbeatRuns.id)).where(and(
|
|
inArray(heartbeatRuns.id, ids), eq(nativeRunFinalizations.controllerBootId, identity.bootId),
|
|
gt(nativeRunFinalizations.leaseExpiresAt, sql`clock_timestamp()`),
|
|
sql`${heartbeatRuns.resultJson} ? 'nativeRuntimeResponses'`,
|
|
));
|
|
for (const row of rows) for (const [key, command] of Object.entries(commands(row.result))) {
|
|
signal.throwIfAborted();
|
|
if (command.state !== "pending" || !activeNativeExecutionRunIds.has(row.id)) continue;
|
|
let state: ResponseCommand["state"] = "queued";
|
|
try {
|
|
const { resolveNativeRuntimeRequest, NativeRuntimeRequestResolutionError } = await import("./native-session-executor.js");
|
|
try {
|
|
await resolveNativeRuntimeRequest({ runId: row.id, requestId: command.request.requestId,
|
|
turnId: command.request.turnId, resolution: command.resolution, commandId: command.commandId,
|
|
authorizeBeforeDispatch: async () => { await authorize(db, command); } });
|
|
} catch (error) {
|
|
if (error instanceof NativeRuntimeRequestResolutionError && error.code === "runtime_request_stale_turn") {
|
|
throw new RuntimeResponseObsoleteError();
|
|
}
|
|
if (!(error instanceof NativeRuntimeRequestResolutionError)
|
|
|| !["native_session_not_active", "runtime_request_resolution_unsupported"].includes(error.code)) throw error;
|
|
const { pending, actor } = await authorize(db, command);
|
|
const { queueRunnerPrpRuntimeRequestResolution } = await import("../../realtime/runner-prp-ws.js");
|
|
queueRunnerPrpRuntimeRequestResolution({ companyId: pending.companyId, runId: row.id,
|
|
pendingRequest: pending, actor, resolution: command.resolution, commandId: command.commandId });
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof RuntimeResponseObsoleteError) state = "obsolete";
|
|
else if (error instanceof NativeRuntimeRequestResolutionAuthorizationError) state = "denied";
|
|
else throw error; // The concrete pending command remains retryable.
|
|
}
|
|
await db.transaction(async tx => {
|
|
const [current] = await tx.select({ result: heartbeatRuns.resultJson }).from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, row.id)).for("update").limit(1);
|
|
const saved = commands(current?.result ?? null);
|
|
if (saved[key]?.commandId !== command.commandId || saved[key]?.state !== "pending") return;
|
|
await tx.update(heartbeatRuns).set({ resultJson: { ...current.result, nativeRuntimeResponses: {
|
|
...saved, [key]: { ...saved[key], state },
|
|
} }, updatedAt: new Date() }).where(eq(heartbeatRuns.id, row.id));
|
|
});
|
|
}
|
|
},
|
|
hasPending: async () => false,
|
|
});
|
|
const unsubscribe = subscribeActiveNativeExecutions(worker.wake);
|
|
return { ready: worker.ready, async stop() { unsubscribe(); await worker.stop(); } };
|
|
}
|