Files
PaperClipAI/packages/db/src/schema/native_run_finalizations.ts
T
DottaandPaperclip 4d2af732ae feat(runner): add native persistence contracts (#12169)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent runs need durable records so Paperclip can explain results and
final status changes.
> - The current heartbeat tables support direct adapters, but they do
not model native runner evidence.
> - The runner transport and server coordinator must share a strict
finalization contract before they write production data.
> - This pull request adds that contract and its additive database
boundary.
> - It does not select the Paperclip Runner or change any existing
adapter execution path.
> - The benefit is a reviewable persistence layer that preserves all
current behavior and supports later guarded integration.

## Linked Issues or Issue Description

Refs #11962

Refs #12129

## What Changed

- Add native run result, finalization, completion, assessment, status
decision, and status effect tables.
- Add inert native metadata to heartbeat runs and events. Keep `legacy`
as the default runtime mode.
- Bind each evidence relationship to one company, issue, run, contract,
result, assessment, and decision with composite constraints.
- Add a strict `paperclip.native_finalization.v1` shared type and
validator.
- Preserve database functions, triggers, and the unique indexes required
by foreign keys in JavaScript backups.
- Add migration, backup, mixed-owner denial, validator, and
direct-adapter compatibility tests.
- Document the new records and their ownership rules.

## Verification

- Run `pnpm -r typecheck`.
- Run `pnpm build`.
- Run `pnpm db:generate`. The schema output and migration safety checks
remain current.
- Run
`PAPERCLIP_PSQL_PATH=/Applications/Postgres.app/Contents/Versions/latest/bin/psql
pnpm exec vitest run
packages/shared/src/validators/native-finalization.test.ts
packages/db/src/client.test.ts packages/db/src/backup-lib.test.ts
server/src/__tests__/heartbeat-workspace-busy.test.ts
server/src/__tests__/heartbeat-comment-wake-batching.test.ts`. All 52
tests pass.
- The full local `pnpm test:run` run completed 4,688 tests. It found 30
existing macOS test-environment failures. A serial rerun with the
canonical `/private/tmp` path reduced those failures to six existing
listener-diagnostics and skill-browser cases. None of those suites use
files in this change.
- The full Linux GitHub Actions matrix passes. This includes all
general-server, serialized-server, workspace, browser, build, typecheck,
canary, and aggregate verification jobs.
- Greptile passes at 5/5. Contributor trust, Superagent, Socket, and
Snyk pass with no finding from this change.
- Storybook visual regression skips by path because this pull request
has no UI or Storybook change.
- Confirm that the diff contains 25 files. Confirm that it contains no
workflow or `pnpm-lock.yaml` changes.

## Risks

- The migration adds tables, columns, indexes, a function, a trigger,
and ownership constraints. It does not remove or rename existing data.
- Composite foreign keys reject mixed-company, mixed-issue, and
mixed-run evidence even when each ID exists.
- The status-version trigger runs only when an issue status changes.
Backup tests confirm that restore retains this trigger and its
dependencies.
- Native source identifiers are unique when present. Legacy event rows
remain unchanged.
- This change does not add a unique run sequence constraint. The later
native writer must allocate its sequence atomically before that
invariant can be safe.
- Existing adapters keep their current execution and finalization paths.
New heartbeat runs default to `legacy` mode.

> 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 — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex with GPT-5. The exact deployment ID and context-window
size are not exposed. The model used agentic reasoning, repository
tools, code execution, and test execution.

## 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 (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [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 any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-25 13:08:39 -05:00

96 lines
3.3 KiB
TypeScript

import { sql } from "drizzle-orm";
import {
check,
foreignKey,
pgTable,
uuid,
text,
timestamp,
integer,
jsonb,
} from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { nativeRunResults } from "./native_run_results.js";
import { statusDecisions } from "./status_decisions.js";
import { workAssessments } from "./work_assessments.js";
export const nativeRunFinalizations = pgTable(
"native_run_finalizations",
{
runId: uuid("run_id").primaryKey(),
companyId: uuid("company_id").notNull().references(() => companies.id),
issueId: uuid("issue_id").notNull(),
phase: text("phase").notNull(),
attempt: integer("attempt").notNull().default(0),
leaseOwner: text("lease_owner"),
leaseExpiresAt: timestamp("lease_expires_at", { withTimezone: true }),
resultId: uuid("result_id"),
assessmentId: uuid("assessment_id"),
decisionId: uuid("decision_id"),
failureCode: text("failure_code"),
failureDetail: jsonb("failure_detail").$type<Record<string, unknown>>(),
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
issueCompanyFk: foreignKey({
columns: [table.companyId, table.issueId],
foreignColumns: [issues.companyId, issues.id],
name: "native_run_finalizations_issue_company_fk",
}),
runOwnerFk: foreignKey({
columns: [table.companyId, table.issueId, table.runId],
foreignColumns: [heartbeatRuns.companyId, heartbeatRuns.nativeIssueId, heartbeatRuns.id],
name: "native_run_finalizations_run_owner_fk",
}),
resultOwnerFk: foreignKey({
columns: [table.companyId, table.issueId, table.runId, table.resultId],
foreignColumns: [
nativeRunResults.companyId,
nativeRunResults.issueId,
nativeRunResults.runId,
nativeRunResults.id,
],
name: "native_run_finalizations_result_owner_fk",
}),
assessmentOwnerFk: foreignKey({
columns: [table.companyId, table.issueId, table.runId, table.assessmentId],
foreignColumns: [
workAssessments.companyId,
workAssessments.issueId,
workAssessments.runId,
workAssessments.id,
],
name: "native_run_finalizations_assessment_owner_fk",
}),
decisionOwnerFk: foreignKey({
columns: [
table.companyId,
table.issueId,
table.runId,
table.assessmentId,
table.decisionId,
],
foreignColumns: [
statusDecisions.companyId,
statusDecisions.issueId,
statusDecisions.runId,
statusDecisions.assessmentId,
statusDecisions.id,
],
name: "native_run_finalizations_decision_owner_fk",
}),
assessmentRequiresResultCheck: check(
"native_run_finalizations_assessment_requires_result_check",
sql`${table.assessmentId} is null or ${table.resultId} is not null`,
),
decisionRequiresAssessmentCheck: check(
"native_run_finalizations_decision_requires_assessment_check",
sql`${table.decisionId} is null or ${table.assessmentId} is not null`,
),
}),
);