Files
PaperClipAI/server/src/services/provider-trace-workspace-diff-reprojection.ts
T
DottaandDev Agent 25cf079ec5 feat(runner): add Codex-native application integration (#12591)
## 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>
2026-08-31 14:38:38 -05:00

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,
};
}