mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The runner package is useful only when the application can start, observe, and recover a native Codex run safely. > - Existing direct adapters must keep their current execution and finalization paths. > - The application boundary therefore needs additive persistence, authorization, coordination, and recovery behind an explicit experimental adapter. > - This pull request adds that Codex-only boundary without activating generalized providers, remote environments, or the later task/SDK surfaces. ## Linked Issues or Issue Description **Subsystem affected** Shared contracts, database persistence, adapter utilities, server native-runtime services, and the experimental Paperclip Runner adapter. **Problem or motivation** The already-landed runner package has a qualified Codex path, but the application needs durable native-run state, guarded runtime selection, authenticated coordination, tool security, finalization, and recovery before the experimental adapter can be exercised safely. **Proposed solution** Add a Codex-only `paperclip_runner` application path behind the existing default-off native-runner setting. Bind native state and coordination to company/run identity, preserve persisted-run recovery, and leave every direct adapter on its existing legacy execution path. **Alternatives considered** The earlier stack boundary introduced a generalized executor and remote-environment lifecycle here. That made this PR depend on implementations in higher PRs and changed reusable sandbox behavior globally. Those pieces are now deferred together to #12592. **Roadmap alignment** ROADMAP.md does not list a conflicting native-runner integration project. This change adds the application boundary for the existing Runner architecture. ## What Changed - Added native run/result/finalization/provider-trace persistence, shared validators, and idempotent migration/replay coverage. - Added guarded Codex-only runtime selection, authenticated PRP coordination, recovery, finalization, and interaction services. - Added run/company-bound tool-gateway authorization, credential redaction, SSRF protections, and replay-safe behavior. - Added the explicit `paperclip_runner` adapter behind the default-off rollout setting. - Preserved legacy answered-question wake projection and direct-adapter execution/finalization paths. - Hardened cancellation so only owned in-memory child processes are signaled; persisted recycled PIDs/process groups are never trusted. - Retained the narrow Claude ACPX isolated-context security follow-up discovered after #12590. - Deferred the generalized executor, provider ingress, remote lifecycle, SDK/lab/eval work, release-process changes, and lockfile. ## Verification - Changed-file delta against `master`: 133 files. - GitHub Actions is the authoritative verification environment for this PR. - Full CI, security, and Greptile review will run on this lowest unmerged stack PR. - Local tests/build/typecheck were not run because this checkout is resource constrained. - Static diff/reference checks pass, and `pnpm-lock.yaml` is unchanged. ## Risks - This touches central heartbeat and agent-route code, so legacy compatibility is the primary risk. - Runtime selection remains Codex-only and explicit; direct Codex, Claude, OpenCode, process, HTTP, and plugin adapters remain on their existing paths. - Fresh native starts fail closed while the rollout flag is off; persisted native records remain readable and recoverable. - Cancellation, company/run binding, tool calls, status decisions, and completion writes are guarded or replay-safe. > 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. ## Model Used OpenAI Codex, GPT-5.6, with repository tools, code execution, and parallel agent review. ## 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 linked existing issues or described the issue in-PR following the relevant issue template - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id - [ ] I have run tests locally and they pass — GitHub Actions is authoritative for this resource-constrained checkout - [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 risks above - [ ] All Paperclip CI and security gates are green - [ ] Greptile is 5/5 with no open actionable findings - [x] I will address all Greptile and reviewer comments before merge ## Stack - Position: 3 of 5 overall; lowest of 3 currently unmerged - Base: `master` - Previous: [#12590](https://github.com/paperclipai/paperclip/pull/12590), qualified Claude ACPX runtime — merged - Next: [#12592](https://github.com/paperclipai/paperclip/pull/12592), generalized Codex executor, task experience, and developer SDKs --------- Co-authored-by: Dev Agent <dev@paperclip.ing>
177 lines
5.7 KiB
TypeScript
177 lines
5.7 KiB
TypeScript
import { and, eq, inArray } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { heartbeatRunEvents } from "@paperclipai/db";
|
|
import { parseCodexTurnDiff } from "../vendor/paperclip-runner/index.js";
|
|
import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js";
|
|
|
|
type WorkspaceDiffPayload = {
|
|
schema: "paperclip.workspace.diff.v1";
|
|
changeSetId: string;
|
|
revision: number;
|
|
source: "runner_verified";
|
|
complete: true;
|
|
files: ReturnType<typeof parseCodexTurnDiff>;
|
|
totals: { files: number; additions: number | null; deletions: number | null };
|
|
patchArtifactRef: null;
|
|
};
|
|
|
|
export type WorkspaceDiffReprojectionSkipReason = {
|
|
reason:
|
|
| "trace_unavailable"
|
|
| "trace_expired"
|
|
| "trace_incomplete"
|
|
| "malformed_frame"
|
|
| "missing_turn_id"
|
|
| "malformed_diff"
|
|
| "already_recorded";
|
|
turnId?: string;
|
|
changeSetId?: string;
|
|
frameId?: number;
|
|
};
|
|
|
|
export interface WorkspaceDiffTraceProjection {
|
|
turns: Array<{ turnId: string; payload: WorkspaceDiffPayload }>;
|
|
skipReasons: WorkspaceDiffReprojectionSkipReason[];
|
|
}
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: {};
|
|
}
|
|
|
|
function stableTurnId(value: unknown): string | null {
|
|
return typeof value === "string" &&
|
|
value.length <= 160 &&
|
|
/^[A-Za-z0-9][A-Za-z0-9._:-]*$/u.test(value)
|
|
? value
|
|
: null;
|
|
}
|
|
|
|
/** Selects the last valid complete Codex diff snapshot for each turn. */
|
|
export function projectCodexWorkspaceDiffsFromTrace(
|
|
entries: Record<string, unknown>[],
|
|
): WorkspaceDiffTraceProjection {
|
|
const revisions = new Map<string, number>();
|
|
const turns = new Map<string, { turnId: string; payload: WorkspaceDiffPayload }>();
|
|
const skipReasons: WorkspaceDiffReprojectionSkipReason[] = [];
|
|
|
|
for (const entry of entries) {
|
|
if (entry.kind !== "frame" || entry.direction !== "provider_to_client") continue;
|
|
const frameId = Number.isSafeInteger(entry.frameId) ? Number(entry.frameId) : undefined;
|
|
let message: Record<string, unknown>;
|
|
try {
|
|
if (typeof entry.rawBase64 !== "string") throw new Error("missing frame bytes");
|
|
message = record(JSON.parse(Buffer.from(entry.rawBase64, "base64").toString("utf8")));
|
|
} catch {
|
|
skipReasons.push({ reason: "malformed_frame", ...(frameId ? { frameId } : {}) });
|
|
continue;
|
|
}
|
|
if (message.method !== "turn/diff/updated") continue;
|
|
const params = record(message.params);
|
|
const turnId = stableTurnId(params.turnId);
|
|
if (turnId === null) {
|
|
skipReasons.push({ reason: "missing_turn_id", ...(frameId ? { frameId } : {}) });
|
|
continue;
|
|
}
|
|
const patch = typeof params.diff === "string" ? params.diff : "";
|
|
const files = parseCodexTurnDiff(patch);
|
|
if (patch.trim().length > 0 && files.length === 0) {
|
|
skipReasons.push({ reason: "malformed_diff", turnId, ...(frameId ? { frameId } : {}) });
|
|
continue;
|
|
}
|
|
const revision = (revisions.get(turnId) ?? 0) + 1;
|
|
revisions.set(turnId, revision);
|
|
const unknown = files.some((file) => file.additions === null || file.deletions === null);
|
|
turns.set(turnId, {
|
|
turnId,
|
|
payload: {
|
|
schema: "paperclip.workspace.diff.v1",
|
|
changeSetId: `${turnId}:workspace`,
|
|
revision,
|
|
source: "runner_verified",
|
|
complete: true,
|
|
files,
|
|
totals: {
|
|
files: files.length,
|
|
additions: unknown ? null : files.reduce((sum, file) => sum + (file.additions ?? 0), 0),
|
|
deletions: unknown ? null : files.reduce((sum, file) => sum + (file.deletions ?? 0), 0),
|
|
},
|
|
patchArtifactRef: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
return { turns: [...turns.values()], skipReasons };
|
|
}
|
|
|
|
export async function persistReprojectedWorkspaceDiffs(
|
|
db: Db,
|
|
input: {
|
|
traceId: string;
|
|
runId: string;
|
|
companyId: string;
|
|
agentId: string;
|
|
projection: WorkspaceDiffTraceProjection;
|
|
},
|
|
) {
|
|
const existing = await db
|
|
.select({ payload: heartbeatRunEvents.payload })
|
|
.from(heartbeatRunEvents)
|
|
.where(and(
|
|
eq(heartbeatRunEvents.runId, input.runId),
|
|
inArray(heartbeatRunEvents.eventType, ["workspace.change.updated", "workspace.diff.recorded"]),
|
|
));
|
|
const recordedChangeSets = new Set(
|
|
existing.flatMap((row) => {
|
|
const payload = record(row.payload);
|
|
return row.payload &&
|
|
payload.complete === true &&
|
|
typeof payload.changeSetId === "string"
|
|
? [payload.changeSetId]
|
|
: [];
|
|
}),
|
|
);
|
|
const skipReasons = [...input.projection.skipReasons];
|
|
let created = 0;
|
|
|
|
for (const [index, candidate] of input.projection.turns.entries()) {
|
|
if (recordedChangeSets.has(candidate.payload.changeSetId)) {
|
|
skipReasons.push({
|
|
reason: "already_recorded",
|
|
turnId: candidate.turnId,
|
|
changeSetId: candidate.payload.changeSetId,
|
|
});
|
|
continue;
|
|
}
|
|
const result = await appendHeartbeatRunEvent(db, {
|
|
companyId: input.companyId,
|
|
runId: input.runId,
|
|
agentId: input.agentId,
|
|
eventType: "workspace.diff.recorded",
|
|
payload: candidate.payload,
|
|
nativeSource: {
|
|
sourceInstanceId: `provider-trace-reproject:${input.traceId}`,
|
|
sourceEventId: `provider-trace-reproject:${input.traceId}:${candidate.payload.changeSetId}`,
|
|
sourceSeq: index + 1,
|
|
protocolSchemaVersion: 1,
|
|
canonicalPayload: candidate.payload,
|
|
},
|
|
});
|
|
if (result.disposition === "committed") created += 1;
|
|
else {
|
|
skipReasons.push({
|
|
reason: "already_recorded",
|
|
turnId: candidate.turnId,
|
|
changeSetId: candidate.payload.changeSetId,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
created,
|
|
skipped: skipReasons.length,
|
|
skipReasons,
|
|
};
|
|
}
|