mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 16:58:52 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The web server owns request handling and remote execution coordination. > - Deployment must start a ready replacement while the old server still serves. > - An outage test does not prove that two live servers can overlap safely. > - This pull request adds a separate Product E2E cell for that boundary. > - The existing run, file, recovery, and retained-session checks still apply. ## Linked Issues or Issue Description Refs #18518. The runtime prerequisite is merged; this PR qualifies its ephemeral server and deployment contracts. ## What Changed - Add the explicit `rolling-web-deployment` Codex Daytona workflow. - Start independent A/B/C homes and compiled builds behind a test ingress. - Wait for readiness before routing, then drain and delete the old home. - Observe active authority before handoff and use the existing read-only recovery oracle. - Require ordinary reads and saves to succeed throughout replacement. Read back every accepted save exactly once. - Preserve the five-minute outage suites and their existing validators. - Add a model-free supervisor smoke and an unready replacement control. - Require graceful exit before deleting an old home; forced cleanup cannot satisfy the oracle. - Preserve a durable Stop through lease release/expiry until the recovered execution owner acknowledges it. Permit only fenced reattachment of the original execution for Stop cleanup; submit no new turn. - Exclude events saved by A during shutdown from evidence of B making progress. ## Verification - E2E TypeScript check passed. - Affected harness checks: 143 passed. Catalog, fixture and report checks: 120 passed. - Actual supervisor smoke passed two replacements: 436 reads, 45 saves, zero request failures, and confirmed owned resource cleanup. - B became ready in 18.7 seconds; C became ready in 109.2 seconds. A prior C timeout remains failed evidence. - Fresh browser proof: 11 checks passed, 246 reads, 50 acknowledged saves, zero failures, 1,058ms reconnect, both old homes deleted and cleanup confirmed. - Stop lease-gap regression: three PostgreSQL delivery tests passed with a simulated provider edge. Current server TypeScript build/vendoring passed. - Full harness: 5,814 passed, 17 failed, four skipped. Two shutdown races passed the focused file; two report timeouts passed an isolated 42-test rerun. Onboarding/workflow-security/Pi/date-expired fixtures are unchanged on inspected master; the host embedded PostgreSQL fixture remains a setup limitation. This is not a green full local suite. - CI at `172509b8be` passed all gates. Greptile is 5/5 with every review thread resolved. The earlier access-service race failure passed its isolated regression and the subsequent full CI. - Final review corrections: six focused executor cases, three PostgreSQL command-delivery cases, 19 harness cases and server/E2E typechecks passed. - Three live attempts failed before agent creation: two harness setup gaps were fixed, then provider authentication failed. The saved OpenAI key returns 401; a refresh is pending. The Daytona probe reported cleanup uncertain, and no sandbox with that attempt label was found through the authenticated staging manager. No live model execution is claimed. - Live Runner overlap, released-container qualification and hosted deployment proof remain pending. Merging this opt-in qualification lane does not claim those results. ## Risks - The source lane shares workspace dependencies and does not establish container packaging correctness. - The live workflow needs an explicitly owned S3 bucket, PostgreSQL, pinned remote image and provider credentials. - Qualification cleanup stops all owned processes after failure. A failed replacement must not be reported as a pass. ## Model Used OpenAI Codex, GPT-6. Tool use, code execution and repository review. The runtime does not expose a more specific model ID or context-window size. ## 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>
147 lines
9.7 KiB
TypeScript
147 lines
9.7 KiB
TypeScript
import { isRollingWebDeployment } from "./controller-rolling-deployment.js";
|
|
import { createHash } from "node:crypto";
|
|
import { remoteWorkspaceAuthoritySchema, remoteWorkspaceRunReferenceSchema } from "../../packages/shared/src/remote-workspace.js";
|
|
import type { ExecutionWorkspace, WorkspaceFileContent } from "../../packages/shared/src/index.js";
|
|
import type { StoryCheck, StoryRun } from "./everyday-observations.js";
|
|
import type { MatrixExecution } from "./types.js";
|
|
import type { RunnerApi } from "./api.js";
|
|
|
|
type RunSummary = Pick<StoryRun, "runtimeMode" | "status" | "agentId" | "finishedAt"> & { runId: string };
|
|
|
|
export const REMOTE_WORKSPACE_STORAGE_SUITE = "remote-workspace-storage";
|
|
export const REMOTE_WORKSPACE_PROOF_FILE = "workspace-proof.txt";
|
|
|
|
export interface RemoteWorkspaceStorageCheckpoint {
|
|
workspaceId: string;
|
|
snapshotId: string;
|
|
runId: string;
|
|
runSnapshotId: string;
|
|
runSaveState: string;
|
|
authoritySchema: string;
|
|
authorityDirty: boolean;
|
|
authorityWriter: string | null;
|
|
providerType: string;
|
|
proofSha256: string;
|
|
fileSource: string;
|
|
}
|
|
|
|
export interface RemoteWorkspaceStorageEvidence {
|
|
initial?: RemoteWorkspaceStorageCheckpoint;
|
|
final?: RemoteWorkspaceStorageCheckpoint;
|
|
browserReadPhases: string[];
|
|
upgradeReadiness?: { version: number; activeRuns: number; recoverableRuns: number; truncated: boolean };
|
|
}
|
|
|
|
export function isRemoteWorkspaceStorageExecution(execution: MatrixExecution): boolean {
|
|
return execution.suite.id === REMOTE_WORKSPACE_STORAGE_SUITE || isRollingWebDeployment(execution);
|
|
}
|
|
|
|
export function remoteWorkspaceStoragePrompt(base: string): string {
|
|
return `${base.replace("Work in the project workspace.", "Work in this task's own workspace.")} This is synthetic test work. Initialize a Git repository in the task workspace if it does not have one. Commit the initial source and README locally; do not push to any external repository. Create ${REMOTE_WORKSPACE_PROOF_FILE} containing a fresh random 32-character lowercase hexadecimal reference, with a trailing newline. Generate the reference inside the task, do not print its value in chat, and leave this file untracked. Keep this exact file and the local commit through later revisions; do not recreate or replace the reference.`;
|
|
}
|
|
|
|
export function remoteWorkspaceProofDigest(content: string): string {
|
|
if (!/^[a-f0-9]{32}\n$/.test(content)) throw new Error("Remote workspace proof must contain its original generated synthetic reference");
|
|
return createHash("sha256").update(content).digest("hex");
|
|
}
|
|
|
|
export async function readRemoteWorkspaceStorageCheckpoint(input: {
|
|
api: Pick<RunnerApi, "get">; companyId: string; agentId: string; issueId: string; environmentId: string;
|
|
}): Promise<{ point?: RemoteWorkspaceStorageCheckpoint; content?: string }> {
|
|
const view = await input.api.get<{ issueId: string; workspace: ExecutionWorkspace | null }>(`/api/issues/${input.issueId}/workspace`);
|
|
const workspace = view.workspace;
|
|
const authorityResult = remoteWorkspaceAuthoritySchema.safeParse(workspace?.metadata?.remoteWorkspace);
|
|
if (view.issueId !== input.issueId || !workspace || workspace.companyId !== input.companyId
|
|
|| workspace.providerType !== "cloud_sandbox" || !authorityResult.success) return {};
|
|
const authority = authorityResult.data;
|
|
if (!authority.snapshotId || authority.dirty || authority.writerRunId || !authority.location
|
|
|| authority.location.environmentId !== input.environmentId) return {};
|
|
const runs = await input.api.get<RunSummary[]>(`/api/issues/${input.issueId}/runs`);
|
|
const latest = runs.filter(run => run.runtimeMode === "native" && run.status === "succeeded"
|
|
&& run.agentId === input.agentId && typeof run.runId === "string")
|
|
.sort((a, b) => String(b.finishedAt).localeCompare(String(a.finishedAt)))[0];
|
|
if (!latest) return {};
|
|
const run = await input.api.get<StoryRun>(`/api/heartbeat-runs/${encodeURIComponent(latest.runId)}`);
|
|
if (run.id !== latest.runId || run.companyId !== input.companyId || run.agentId !== input.agentId
|
|
|| run.nativeIssueId !== input.issueId || run.runtimeMode !== "native" || run.status !== "succeeded") return {};
|
|
const receiptResult = remoteWorkspaceRunReferenceSchema.safeParse(run.runnerProfileJson?.remoteWorkspace);
|
|
if (!receiptResult.success) return {};
|
|
const receipt = receiptResult.data;
|
|
if (receipt.state !== "saved" || receipt.snapshotId !== authority.snapshotId
|
|
|| receipt.workspaceId !== workspace.id || receipt.epoch !== authority.epoch
|
|
|| receipt.leaseId !== authority.location.leaseId || receipt.providerLeaseId !== authority.location.providerLeaseId
|
|
|| receipt.remoteCwd !== authority.location.remoteCwd) return {};
|
|
const file = await input.api.get<WorkspaceFileContent>(`/api/issues/${input.issueId}/file-resources/content?workspace=execution&path=${REMOTE_WORKSPACE_PROOF_FILE}`);
|
|
if (file.content?.encoding !== "utf8" || typeof file.content.data !== "string") throw new Error("Remote workspace file oracle did not return text bytes");
|
|
if (file.resource?.source?.kind !== "live" || file.resource.workspaceKind !== "execution_workspace"
|
|
|| file.resource.workspaceId !== workspace.id) return {};
|
|
return { content: file.content.data, point: {
|
|
workspaceId: workspace.id, snapshotId: authority.snapshotId, runId: run.id,
|
|
runSnapshotId: receipt.snapshotId, runSaveState: receipt.state,
|
|
authoritySchema: authority.schema, authorityDirty: authority.dirty,
|
|
authorityWriter: authority.writerRunId, providerType: workspace.providerType,
|
|
proofSha256: remoteWorkspaceProofDigest(file.content.data), fileSource: file.resource?.source?.kind,
|
|
} };
|
|
}
|
|
|
|
export function gradeRemoteWorkspaceStorage(evidence: RemoteWorkspaceStorageEvidence): StoryCheck[] {
|
|
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
const committed = (point: RemoteWorkspaceStorageCheckpoint | undefined) => Boolean(point
|
|
&& uuid.test(point.workspaceId) && uuid.test(point.snapshotId) && uuid.test(point.runId)
|
|
&& point.snapshotId === point.runSnapshotId && point.runSaveState === "saved"
|
|
&& point.authoritySchema === "paperclip.remote-workspace.v1" && point.authorityDirty === false
|
|
&& point.authorityWriter === null && point.providerType === "cloud_sandbox"
|
|
&& point.fileSource === "live" && /^[a-f0-9]{64}$/.test(point.proofSha256));
|
|
return [
|
|
{ id: "remote-workspace-initial-save", passed: committed(evidence.initial), detail: "The initial task admission commits a remote workspace snapshot and a matching saved run receipt." },
|
|
{ id: "remote-workspace-final-save", passed: committed(evidence.final), detail: "The later admission commits its files before finalization succeeds." },
|
|
{ id: "remote-workspace-proof-retained", passed: Boolean(committed(evidence.initial) && committed(evidence.final)
|
|
&& evidence.initial!.workspaceId === evidence.final!.workspaceId
|
|
&& evidence.initial!.runId !== evidence.final!.runId
|
|
&& evidence.initial!.snapshotId !== evidence.final!.snapshotId
|
|
&& evidence.initial!.proofSha256 === evidence.final!.proofSha256),
|
|
detail: "The same remote workspace retains its generated untracked reference through controller replacement and later native admission." },
|
|
{ id: "remote-workspace-browser-read", passed: ["before-restart", "after-restart"].every(phase => evidence.browserReadPhases.includes(phase)),
|
|
detail: "Ordinary browser file navigation renders the independently read workspace reference before and after controller replacement." },
|
|
{ id: "remote-workspace-upgrade-recoverable", passed: Boolean(evidence.upgradeReadiness
|
|
&& evidence.upgradeReadiness.version === 1 && evidence.upgradeReadiness.truncated === false
|
|
&& Number.isSafeInteger(evidence.upgradeReadiness.activeRuns)
|
|
&& Number.isSafeInteger(evidence.upgradeReadiness.recoverableRuns)
|
|
&& evidence.upgradeReadiness.activeRuns >= 1 && evidence.upgradeReadiness.recoverableRuns >= 1
|
|
&& evidence.upgradeReadiness.recoverableRuns <= evidence.upgradeReadiness.activeRuns),
|
|
detail: "The ordinary instance upgrade probe recognizes an active remote workspace execution as recoverable before controller replacement." },
|
|
];
|
|
}
|
|
|
|
/** Only this explicit synthetic workflow can opt the isolated server into S3. */
|
|
export function remoteWorkspaceStorageServerEnvironment(input: {
|
|
executions: readonly MatrixExecution[];
|
|
env: NodeJS.ProcessEnv;
|
|
instanceId: string;
|
|
}): NodeJS.ProcessEnv | undefined {
|
|
if (!input.executions.some(isRemoteWorkspaceStorageExecution)) return undefined;
|
|
if (!input.executions.length || !input.executions.every(isRemoteWorkspaceStorageExecution)) {
|
|
throw new Error("Remote workspace storage qualification must run alone");
|
|
}
|
|
const bucket = input.env.PAPERCLIP_RUNNER_E2E_WORKSPACE_STORAGE_BUCKET?.trim();
|
|
if (!bucket || !/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket)) {
|
|
throw new Error("Remote workspace storage requires an explicit disposable S3 bucket");
|
|
}
|
|
if (!/^[a-zA-Z0-9._-]+$/.test(input.instanceId)) throw new Error("Invalid isolated instance identity");
|
|
const endpoint = input.env.PAPERCLIP_RUNNER_E2E_WORKSPACE_STORAGE_ENDPOINT?.trim();
|
|
if (endpoint) {
|
|
const url = new URL(endpoint);
|
|
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
throw new Error("Remote workspace storage endpoint must not contain credentials");
|
|
}
|
|
}
|
|
return {
|
|
PAPERCLIP_STORAGE_PROVIDER: "s3",
|
|
PAPERCLIP_STORAGE_S3_BUCKET: bucket,
|
|
PAPERCLIP_STORAGE_S3_REGION: input.env.PAPERCLIP_RUNNER_E2E_WORKSPACE_STORAGE_REGION?.trim() || "us-east-1",
|
|
PAPERCLIP_STORAGE_S3_PREFIX: `runner-e2e/${input.instanceId}/`,
|
|
...(endpoint ? { PAPERCLIP_STORAGE_S3_ENDPOINT: endpoint,
|
|
PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE: input.env.PAPERCLIP_RUNNER_E2E_WORKSPACE_STORAGE_FORCE_PATH_STYLE === "true" ? "true" : "false" } : {}),
|
|
};
|
|
}
|