Files
PaperClipAI/server/src/__tests__/native-finalization-migration.test.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

133 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { eq, sql } from "drizzle-orm";
import {
applyPendingMigrations,
createDb,
heartbeatRunEvents,
heartbeatRuns,
} from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
describe("P6-18 / MIG-01..04 native finalization migration", () => {
it("repairs only later duplicates and preserves legacy event bytes and cursors", async () => {
const temporary = await startEmbeddedPostgresTestDatabase("paperclip-native-migration-");
const migration = await readFile(
new URL("../../../packages/db/src/migrations/0227_modern_pandemic.sql", import.meta.url),
"utf8",
);
const migrationHash = createHash("sha256").update(migration).digest("hex");
const sequenceMigration = await readFile(
new URL("../../../packages/db/src/migrations/0235_heartbeat_run_event_sequence_uniqueness.sql", import.meta.url),
"utf8",
);
const sequenceMigrationHash = createHash("sha256")
.update(sequenceMigration)
.digest("hex");
const rawDb = createDb(temporary.connectionString);
try {
const companyId = "10000000-0000-4000-8000-000000000001";
const agentId = "10000000-0000-4000-8000-000000000002";
const runId = "10000000-0000-4000-8000-000000000003";
// Reconstruct the actual pre-0227 shape rather than extracting selected
// repair statements from the migration under test.
await rawDb.execute(sql.raw(`
DROP TABLE IF EXISTS status_decision_effects, status_decisions, work_assessments,
native_run_finalizations, native_run_results, completion_contracts CASCADE;
DROP TRIGGER IF EXISTS paperclip_issue_status_version_trigger ON issues;
DROP FUNCTION IF EXISTS paperclip_bump_issue_status_version();
DROP INDEX IF EXISTS heartbeat_run_events_run_seq_uq;
CREATE INDEX IF NOT EXISTS heartbeat_run_events_run_seq_idx
ON heartbeat_run_events (run_id, seq);
DROP INDEX IF EXISTS heartbeat_run_events_run_source_event_uq;
DROP INDEX IF EXISTS heartbeat_run_events_run_source_seq_uq;
ALTER TABLE heartbeat_run_events
DROP COLUMN IF EXISTS source_instance_id,
DROP COLUMN IF EXISTS source_event_id,
DROP COLUMN IF EXISTS source_seq,
DROP COLUMN IF EXISTS source_payload_sha256,
DROP COLUMN IF EXISTS protocol_schema_version;
ALTER TABLE heartbeat_run_events ALTER COLUMN seq TYPE integer;
ALTER TABLE heartbeat_runs
DROP COLUMN IF EXISTS runtime_mode,
DROP COLUMN IF EXISTS runtime_mode_resolver_version,
DROP COLUMN IF EXISTS runtime_mode_reason,
DROP COLUMN IF EXISTS runtime_mode_resolved_at,
DROP COLUMN IF EXISTS runner_profile_json,
DROP COLUMN IF EXISTS runner_instance_id,
DROP COLUMN IF EXISTS native_session_id,
DROP COLUMN IF EXISTS driver_kind,
DROP COLUMN IF EXISTS driver_version,
DROP COLUMN IF EXISTS completion_contract_id,
DROP COLUMN IF EXISTS completion_contract_sha256,
DROP COLUMN IF EXISTS next_event_seq,
DROP COLUMN IF EXISTS native_phase,
DROP COLUMN IF EXISTS native_phase_updated_at;
ALTER TABLE issues
DROP COLUMN IF EXISTS status_version,
DROP COLUMN IF EXISTS last_status_decision_id;
`));
await rawDb.execute(sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${migrationHash}`);
await rawDb.execute(sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${sequenceMigrationHash}`);
await rawDb.execute(sql`
INSERT INTO companies (id, name, issue_prefix)
VALUES (${companyId}, 'Migration fixture', 'MIG')
`);
await rawDb.execute(sql`
INSERT INTO agents (id, company_id, name)
VALUES (${agentId}, ${companyId}, 'Migration agent')
`);
await rawDb.execute(sql`
INSERT INTO heartbeat_runs (id, company_id, agent_id, status)
VALUES (${runId}, ${companyId}, ${agentId}, 'succeeded')
`);
await rawDb.execute(sql`
INSERT INTO heartbeat_run_events
(company_id, run_id, agent_id, seq, event_type, stream, level, message, payload, created_at)
VALUES
(${companyId}, ${runId}, ${agentId}, 1, 'legacy.start', 'system', 'info', 'one', ${JSON.stringify({ bytes: "α-1" })}::jsonb, '2026-08-01T00:00:01.000Z'),
(${companyId}, ${runId}, ${agentId}, 5, 'legacy.log', 'stdout', 'info', 'first-five', ${JSON.stringify({ bytes: "β-5a" })}::jsonb, '2026-08-01T00:00:02.000Z'),
(${companyId}, ${runId}, ${agentId}, 5, 'legacy.log', 'stderr', 'warn', 'duplicate-five', ${JSON.stringify({ bytes: "γ-5b" })}::jsonb, '2026-08-01T00:00:03.000Z'),
(${companyId}, ${runId}, ${agentId}, 9, 'legacy.end', 'system', 'info', 'nine', ${JSON.stringify({ bytes: "δ-9" })}::jsonb, '2026-08-01T00:00:04.000Z')
`);
const beforeResult = await rawDb.execute(sql`
SELECT * FROM heartbeat_run_events WHERE run_id = ${runId} ORDER BY id
`);
const before = [...beforeResult] as unknown as Record<string, unknown>[];
await applyPendingMigrations(temporary.connectionString);
const db = createDb(temporary.connectionString);
const after = await db.select().from(heartbeatRunEvents)
.where(eq(heartbeatRunEvents.runId, runId)).orderBy(heartbeatRunEvents.id);
expect(after.map((row) => row.seq)).toEqual([1, 5, 10, 9]);
expect((await db.select({ nextEventSeq: heartbeatRuns.nextEventSeq }).from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId)))[0]?.nextEventSeq).toBe(11);
// The repaired duplicate's cursor is the only changed byte-equivalent read field.
const legacyColumns = (row: Record<string, unknown>) => ({
id: String(row.id),
companyId: row.companyId ?? row.company_id,
runId: row.runId ?? row.run_id,
agentId: row.agentId ?? row.agent_id,
eventType: row.eventType ?? row.event_type,
stream: row.stream,
level: row.level,
message: row.message,
payload: row.payload,
createdAt: new Date(String(row.createdAt ?? row.created_at)).toISOString(),
});
expect(after.map((row) => legacyColumns(row))).toEqual(before.map(legacyColumns));
expect(after[0]?.seq).toBe(Number(before[0]?.seq));
expect(after[1]?.seq).toBe(Number(before[1]?.seq));
expect(after[3]?.seq).toBe(Number(before[3]?.seq));
await expect(db.insert(heartbeatRunEvents).values({
companyId, runId, agentId, seq: 5, eventType: "must-conflict",
})).rejects.toThrow();
} finally {
await temporary.cleanup();
}
}, 60_000);
});