mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-09 16:35:27 +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>
133 lines
6.7 KiB
TypeScript
133 lines
6.7 KiB
TypeScript
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);
|
||
});
|