mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +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>
127 lines
8.5 KiB
TypeScript
127 lines
8.5 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import { afterAll, beforeAll, expect, it, vi } from "vitest";
|
|
import { agents, companies, companyMemberships, instanceUserRoles, issues, heartbeatRuns, heartbeatRunEvents,
|
|
nativeRunFinalizations, createDb } from "@paperclipai/db";
|
|
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
|
import { deploymentNotifications } from "../services/deployment-notifications.js";
|
|
import { enqueueNativeRuntimeResponse, registerNativeRuntimeResponseDelivery } from "../services/native-runtime/native-runtime-response-delivery.js";
|
|
import { currentNativeControllerIdentity } from "../services/native-runtime/native-restart-recovery.js";
|
|
import { setNativeExecutionActive } from "../services/native-runtime/native-active-runs.js";
|
|
import type { PendingNativeRuntimeRequest } from "../services/native-runtime/runtime-request-resolution-authority.js";
|
|
const provider = vi.hoisted(() => ({ resolve: vi.fn(), accepted: vi.fn() }));
|
|
vi.mock("../services/native-runtime/native-session-executor.js", () => ({
|
|
resolveNativeRuntimeRequest: (...args: unknown[]) => provider.resolve(...args),
|
|
NativeRuntimeRequestResolutionError: class extends Error { constructor(readonly code: string) { super(code); } },
|
|
}));
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let a: ReturnType<typeof createDb>, b: ReturnType<typeof createDb>;
|
|
let companyId: string, runId: string, agentId: string, userId: string;
|
|
let bridgeA: ReturnType<typeof deploymentNotifications>, bridgeB: ReturnType<typeof deploymentNotifications>;
|
|
let coordinatorA: ReturnType<typeof createDeliveryWorkCoordinator>, coordinatorB: ReturnType<typeof createDeliveryWorkCoordinator>;
|
|
let worker: ReturnType<typeof registerNativeRuntimeResponseDelivery>;
|
|
const errors: unknown[] = [];
|
|
let seq = 0;
|
|
beforeAll(async () => {
|
|
database = await startEmbeddedPostgresTestDatabase("runtime-response-delivery-");
|
|
a = createDb(database.connectionString); b = createDb(database.connectionString);
|
|
companyId = randomUUID(); runId = randomUUID(); agentId = randomUUID(); userId = randomUUID();
|
|
const issueId = randomUUID(), identity = await currentNativeControllerIdentity();
|
|
await a.insert(companies).values({ id: companyId, name: "Response fixture", issuePrefix: "RESP" });
|
|
await a.insert(agents).values({ id: agentId, companyId, name: "Remote fixture" });
|
|
await a.insert(issues).values({ id: issueId, companyId, title: "Response fixture", status: "in_progress" });
|
|
await a.insert(heartbeatRuns).values({ id: runId, companyId, agentId, runtimeMode: "native", status: "running", nativeIssueId: issueId });
|
|
await a.insert(nativeRunFinalizations).values({ runId, companyId, issueId, phase: "executing", controllerBootId: identity.bootId,
|
|
leaseOwner: "fixture-owner", leaseExpiresAt: new Date(Date.now() + 60_000) });
|
|
await a.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "operator" });
|
|
await a.insert(instanceUserRoles).values({ userId, role: "instance_admin" });
|
|
coordinatorA = createDeliveryWorkCoordinator({ owner: a, canRun: () => true, onError: error => errors.push(error) });
|
|
coordinatorB = createDeliveryWorkCoordinator({ owner: b, canRun: () => true, onError: error => errors.push(error) });
|
|
bridgeA = deploymentNotifications(a, coordinatorA, { directDatabaseUrl: database.connectionString });
|
|
bridgeB = deploymentNotifications(b, coordinatorB, { directDatabaseUrl: database.connectionString });
|
|
await Promise.all([bridgeA.ready(), bridgeB.ready()]);
|
|
provider.resolve.mockImplementation(async (input: { authorizeBeforeDispatch: () => Promise<void>; commandId: string }) => {
|
|
await input.authorizeBeforeDispatch();
|
|
provider.accepted(input.commandId);
|
|
return { commandId: input.commandId };
|
|
});
|
|
worker = registerNativeRuntimeResponseDelivery(a, coordinatorA); await worker.ready;
|
|
}, 90_000);
|
|
afterAll(async () => {
|
|
setNativeExecutionActive(runId, false); await worker?.stop();
|
|
await Promise.all([bridgeA?.stop(), bridgeB?.stop()]);
|
|
await Promise.all([coordinatorA?.stop(), coordinatorB?.stop()]);
|
|
await Promise.all([a?.$client.end({ timeout: 1 }), b?.$client.end({ timeout: 1 })]); await database?.cleanup();
|
|
});
|
|
async function pending(): Promise<PendingNativeRuntimeRequest> {
|
|
const request = { companyId, runId, requestId: randomUUID(), requestKind: "command_approval" as const,
|
|
turnId: "turn-fixture", resolverPolicy: "instance_admin" as const };
|
|
await a.insert(heartbeatRunEvents).values({ companyId, runId, agentId, seq: ++seq, eventType: "runtime_request.created",
|
|
payload: { prpEvent: { schema: "paperclip.prp.event.v1", eventType: "runtime_request.created", sourceKind: "runner", runId,
|
|
payload: { request: { ...request, status: "pending" } } } } });
|
|
return request;
|
|
}
|
|
const enqueue = (request: PendingNativeRuntimeRequest) => enqueueNativeRuntimeResponse(b, { request,
|
|
actor: { type: "user", userId, isInstanceAdmin: true }, localImplicit: false, resolution: { action: "decline" } });
|
|
async function state(requestId: string) {
|
|
const [run] = await b.select({ result: heartbeatRuns.resultJson }).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
|
const entries = run.result?.nativeRuntimeResponses as Record<string, { state: string }>;
|
|
return entries?.[createHash("sha256").update(requestId).digest("hex")]?.state;
|
|
}
|
|
it("persists B's response before an owner is attached, then dispatches once through A", async () => {
|
|
const request = await pending();
|
|
const saved = await enqueue(request);
|
|
expect(await enqueue(request)).toEqual(saved);
|
|
expect(await state(request.requestId)).toBe("pending");
|
|
expect(provider.resolve).not.toHaveBeenCalled();
|
|
setNativeExecutionActive(runId, true);
|
|
await expect.poll(() => state(request.requestId)).toBe("queued");
|
|
expect(provider.resolve).toHaveBeenCalledTimes(1);
|
|
expect(provider.resolve.mock.calls[0]?.[0].commandId).toBe(saved.commandId);
|
|
await enqueue(request);
|
|
expect(provider.resolve).toHaveBeenCalledTimes(1);
|
|
await expect(enqueueNativeRuntimeResponse(b, { request, actor: { type: "user", userId, isInstanceAdmin: true },
|
|
localImplicit: false, resolution: { action: "cancel" } })).rejects.toThrow("different response");
|
|
expect(errors).toEqual([]);
|
|
});
|
|
it("rechecks company access even for an administrator before dispatch", async () => {
|
|
setNativeExecutionActive(runId, false);
|
|
const request = await pending(); await enqueue(request);
|
|
await a.update(companyMemberships).set({ status: "inactive" }).where(eq(companyMemberships.principalId, userId));
|
|
setNativeExecutionActive(runId, true);
|
|
await expect.poll(() => state(request.requestId)).toBe("denied");
|
|
expect(provider.accepted).not.toHaveBeenCalled();
|
|
expect(errors).toEqual([]);
|
|
});
|
|
it("does not dispatch an answer after its request has become terminal", async () => {
|
|
setNativeExecutionActive(runId, false);
|
|
const request = await pending(); await enqueue(request);
|
|
await a.insert(heartbeatRunEvents).values({ companyId, runId, agentId, seq: ++seq, eventType: "runtime_request.resolved",
|
|
payload: { prpEvent: { payload: { requestId: request.requestId } } } });
|
|
setNativeExecutionActive(runId, true);
|
|
await expect.poll(() => state(request.requestId)).toBe("obsolete");
|
|
expect(provider.accepted).not.toHaveBeenCalled();
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
it("retires a stale provider turn and still delivers the next pending answer", async () => {
|
|
setNativeExecutionActive(runId, false);
|
|
await a.update(companyMemberships).set({ status: "active" }).where(eq(companyMemberships.principalId, userId));
|
|
const stale = await pending(), current = await pending();
|
|
await enqueue(stale);
|
|
await enqueue(current);
|
|
const { NativeRuntimeRequestResolutionError } = await import("../services/native-runtime/native-session-executor.js");
|
|
provider.resolve.mockImplementation(async (input: { requestId: string; authorizeBeforeDispatch: () => Promise<void>; commandId: string }) => {
|
|
if (input.requestId === stale.requestId) throw new NativeRuntimeRequestResolutionError("runtime_request_stale_turn", "fixture stale turn");
|
|
await input.authorizeBeforeDispatch();
|
|
provider.accepted(input.commandId);
|
|
return { commandId: input.commandId };
|
|
});
|
|
setNativeExecutionActive(runId, true);
|
|
await expect.poll(() => state(stale.requestId)).toBe("obsolete");
|
|
await expect.poll(() => state(current.requestId)).toBe("queued");
|
|
expect(provider.accepted).toHaveBeenCalledTimes(1);
|
|
expect(errors).toEqual([]);
|
|
});
|