mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
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>
This commit is contained in:
1 parent
a7e6b818e9
commit
25cf079ec5
133 files changed
+103484
-703
No files matched your search
@@ -143,6 +143,16 @@ type TranscriptBlock =
|
||||
text: string;
|
||||
detail?: string;
|
||||
}
|
||||
| {
|
||||
type: "provider_activity";
|
||||
ts: string;
|
||||
family: Extract<TranscriptEntry, { kind: "provider_activity" }>["family"];
|
||||
eventType: string;
|
||||
status: Extract<TranscriptEntry, { kind: "provider_activity" }>["status"];
|
||||
title: string;
|
||||
summary: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: "diff_group";
|
||||
ts: string;
|
||||
@@ -584,6 +594,43 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.kind === "provider_activity") {
|
||||
blocks.push({ type: "provider_activity", ts: entry.ts, family: entry.family, eventType: entry.eventType, status: entry.status, title: entry.title, summary: entry.summary, payload: entry.payload });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.kind === "workspace_change") {
|
||||
blocks.push({
|
||||
type: "event",
|
||||
ts: entry.ts,
|
||||
label: entry.complete ? "workspace diff" : "workspace changes",
|
||||
tone: "info",
|
||||
text: `${entry.totals.files} changed ${entry.totals.files === 1 ? "file" : "files"}`,
|
||||
detail: entry.source === "runner_verified" ? "Verified from workspace" : "Reported by harness",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.kind === "workspace_file_reference") {
|
||||
blocks.push({ type: "event", ts: entry.ts, label: "file reference", tone: "info", text: entry.displayName, detail: entry.path });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.kind === "runtime_request") {
|
||||
blocks.push({ type: "event", ts: entry.ts, label: `runtime ${entry.requestType}`, tone: entry.status === "pending" ? "warn" : "info", text: entry.prompt, detail: entry.status });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.kind === "run_result") {
|
||||
blocks.push({ type: "event", ts: entry.ts, label: `result · ${entry.disposition}`, tone: entry.disposition === "blocked" ? "warn" : "info", text: entry.summary });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.kind === "run_terminal") {
|
||||
blocks.push({ type: "event", ts: entry.ts, label: "terminal", tone: entry.runState === "failed" ? "error" : "info", text: `${entry.runState} · ${entry.disposition}`, detail: entry.stopReason });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.kind === "tool_call") {
|
||||
const toolUseId = entry.toolUseId ?? extractToolUseId(entry.input);
|
||||
// Streaming runtimes (e.g. ACPX) re-emit the same tool call as its
|
||||
@@ -809,9 +856,34 @@ function transcriptBlockIdentity(block: TranscriptBlock): string {
|
||||
return `diff_group:${block.ts}`;
|
||||
case "event":
|
||||
return `event:${block.label}:${block.ts}`;
|
||||
case "provider_activity":
|
||||
return `provider_activity:${block.eventType}:${block.ts}`;
|
||||
}
|
||||
}
|
||||
|
||||
function TranscriptProviderActivity({ block, density }: { block: Extract<TranscriptBlock, { type: "provider_activity" }>; density: TranscriptDensity }) {
|
||||
const [open, setOpen] = useState(block.status === "running");
|
||||
const steps = Array.isArray(block.payload.steps) ? block.payload.steps.map(asRecord).filter((value): value is Record<string, unknown> => value !== null) : [];
|
||||
const children = Array.isArray(block.payload.children) ? block.payload.children.map(asRecord).filter((value): value is Record<string, unknown> => value !== null) : [];
|
||||
const sources = Array.isArray(block.payload.sources) ? block.payload.sources.map(asRecord).filter((value): value is Record<string, unknown> => value !== null) : [];
|
||||
const output = typeof block.payload.output === "string" ? block.payload.output.slice(-(8 * 1024)) : "";
|
||||
return <div className={cn("rounded-xl border p-2", block.status === "failed" ? "border-red-500/30 bg-red-500/[0.05]" : "border-border/70 bg-muted/20")} data-provider-family={block.family}>
|
||||
<button type="button" className="flex min-h-8 w-full items-center gap-2 text-left" onClick={() => setOpen((value) => !value)} aria-expanded={open}>
|
||||
<span aria-hidden="true">{block.status === "running" ? "⏳" : block.status === "failed" ? "✕" : block.status === "interrupted" ? "■" : "✓"}</span>
|
||||
<strong className={cn("font-mono", density === "compact" ? "text-(length:--text-micro)" : "text-xs")}>{block.title}</strong>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">{block.summary}</span>
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
{open ? <div className="mt-2 space-y-2 border-l border-border pl-5 text-xs">
|
||||
{steps.length > 0 ? <ol className="space-y-1">{steps.map((step, index) => <li key={String(step.stepId ?? index)}><span className="mr-2" aria-hidden="true">{step.status === "completed" ? "✓" : step.status === "blocked" ? "!" : "○"}</span>{String(step.body ?? "")}</li>)}</ol> : null}
|
||||
{children.length > 0 ? <ul className="space-y-1">{children.map((child, index) => <li key={String(child.childId ?? index)}><strong>{String(child.role ?? "Child agent")}</strong> · {String(child.status ?? "unknown")}<div className="text-muted-foreground">{String(child.summary ?? "")}</div></li>)}</ul> : null}
|
||||
{sources.length > 0 ? <ul className="space-y-1">{sources.map((source, index) => { const url = typeof source.url === "string" && /^https?:\/\//.test(source.url) ? source.url : null; return <li key={String(source.sourceId ?? index)}>{url ? <a className="underline" href={url} target="_blank" rel="noreferrer">{String(source.title ?? url)}</a> : String(source.title ?? "Unavailable source")} <span className="text-muted-foreground">Provider-reported</span></li>; })}</ul> : null}
|
||||
{output ? <pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded bg-background p-2 font-mono">{output}</pre> : null}
|
||||
{block.family === "model_identity" ? <div><span className="text-muted-foreground">Requested</span> {String(block.payload.requestedModel ?? "—")} · <span className="text-muted-foreground">Effective</span> {String(block.payload.effectiveModel ?? "—")}</div> : null}
|
||||
</div> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign each block a stable, unique React key. Identity is position-independent
|
||||
* so earlier blocks collapsing (truncation) does not remount the survivors; a
|
||||
@@ -1665,6 +1737,9 @@ function findScrollParent(element: HTMLElement): HTMLElement | Window {
|
||||
}
|
||||
|
||||
function rawEntryContent(entry: TranscriptEntry): string {
|
||||
if (entry.kind === "provider_activity") {
|
||||
return `${entry.eventType}\n${entry.title}: ${entry.summary}`;
|
||||
}
|
||||
if (entry.kind === "tool_call") {
|
||||
return `${entry.name}\n${formatToolPayload(entry.input)}`;
|
||||
}
|
||||
@@ -1677,6 +1752,11 @@ function rawEntryContent(entry: TranscriptEntry): string {
|
||||
if (entry.kind === "init") {
|
||||
return `model=${entry.model}${entry.sessionId ? ` session=${entry.sessionId}` : ""}`;
|
||||
}
|
||||
if (entry.kind === "workspace_change") return `${entry.totals.files} files · ${entry.source} · revision ${entry.revision}`;
|
||||
if (entry.kind === "workspace_file_reference") return `${entry.displayName}\n${entry.path}`;
|
||||
if (entry.kind === "runtime_request") return `${entry.requestType} · ${entry.status}\n${entry.prompt}`;
|
||||
if (entry.kind === "run_result") return `${entry.disposition}\n${entry.summary}`;
|
||||
if (entry.kind === "run_terminal") return `${entry.runState} · ${entry.turnState} · ${entry.disposition}${entry.stopReason ? `\n${entry.stopReason}` : ""}`;
|
||||
return entry.text;
|
||||
}
|
||||
|
||||
@@ -1850,6 +1930,7 @@ export function RunTranscriptView({
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
)}
|
||||
{block.type === "provider_activity" && <TranscriptProviderActivity block={block} density={density} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user