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>
This commit is contained in:
parent
0b01593602
commit
4d2af732ae
|
|
@ -198,6 +198,33 @@ Triage writes serialize on the company and attention-source identity so concurre
|
|||
|
||||
`decision_retention` tracks the last observed source `activityAt`, Keep, reversible archive provenance, and monotonic source/archive versions. `decision_archive_notification_outbox` has a unique key over company, source identity, archive version, and immutable origin agent so repeated sweeps cannot enqueue duplicate notifications; delivery claims are retryable and coalesced per agent.
|
||||
|
||||
## Native runner persistence
|
||||
|
||||
Native runner state is additive to the existing heartbeat tables. Every existing
|
||||
`heartbeat_runs` row defaults to `runtime_mode = 'legacy'`; adding these columns
|
||||
does not select the native runtime or start a runner process. Native execution can
|
||||
record its resolved runtime profile, provider session, driver, completion
|
||||
contract, durable event cursor, and finalization phase on the run when a later
|
||||
rollout explicitly selects it.
|
||||
|
||||
`completion_contracts`, `native_run_results`, `native_run_finalizations`,
|
||||
`work_assessments`, `status_decisions`, and `status_decision_effects` form the
|
||||
append-oriented evidence and status-decision chain. Unique fingerprints,
|
||||
versions, ordinals, and idempotency keys make retries deterministic. Composite
|
||||
foreign keys bind every contract, result, assessment, decision, effect, and
|
||||
finalization to one company, issue, and run. The database rejects mixed-owner
|
||||
evidence even when every referenced ID exists. Native source identities on
|
||||
`heartbeat_run_events` are nullable so legacy events remain readable without
|
||||
rewriting historical rows. Per-run native source identifiers are unique, while
|
||||
the existing legacy sequence behavior remains unchanged until an atomic event
|
||||
allocator is introduced with the native writer.
|
||||
|
||||
Issue `status_version` advances only when `status` changes. The JavaScript backup
|
||||
path includes user-defined functions and triggers so a restored database keeps
|
||||
that invariant. Removing or disabling a future native rollout flag must not
|
||||
delete these records; persisted experimental runs remain available for recovery
|
||||
and inspection.
|
||||
|
||||
## Plugin database namespaces
|
||||
|
||||
The plugin runtime tracks plugin-owned database namespaces and migrations in `plugin_database_namespaces` and `plugin_migrations`. Hosted deployments that separate runtime and migration connections should set `DATABASE_MIGRATION_URL`; plugin namespace migration work uses the migration connection when present.
|
||||
|
|
|
|||
|
|
@ -140,6 +140,21 @@ describeEmbeddedPostgres("runDatabaseBackup", () => {
|
|||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
await sourceSql.unsafe(`
|
||||
CREATE FUNCTION "public"."backup_test_mark_done"()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW."state" := 'done';
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
CREATE TRIGGER "backup_test_mark_done_trigger"
|
||||
BEFORE UPDATE OF "title" ON "public"."backup_test_records"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION "public"."backup_test_mark_done"();
|
||||
`);
|
||||
|
||||
const payload = "x".repeat(8192);
|
||||
for (let index = 0; index < 160; index += 1) {
|
||||
|
|
@ -213,6 +228,18 @@ describeEmbeddedPostgres("runDatabaseBackup", () => {
|
|||
metadata: { index: 159, even: false },
|
||||
},
|
||||
]);
|
||||
|
||||
await restoreSql.unsafe(`
|
||||
UPDATE "public"."backup_test_records"
|
||||
SET "title" = 'triggered'
|
||||
WHERE "title" = 'row-0'
|
||||
`);
|
||||
const triggeredRows = await restoreSql.unsafe<{ state: string }[]>(`
|
||||
SELECT "state"::text AS "state"
|
||||
FROM "public"."backup_test_records"
|
||||
WHERE "title" = 'triggered'
|
||||
`);
|
||||
expect(triggeredRows).toEqual([{ state: "done" }]);
|
||||
} finally {
|
||||
await sourceSql.end();
|
||||
await restoreSql.end();
|
||||
|
|
|
|||
|
|
@ -591,6 +591,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
|||
emitStatement("BEGIN;");
|
||||
emitStatement("SET LOCAL session_replication_role = replica;");
|
||||
emitStatement("SET LOCAL client_min_messages = warning;");
|
||||
emitStatement("SET LOCAL check_function_bodies = false;");
|
||||
emit("");
|
||||
|
||||
const allTables = await sql<TableDefinition[]>`
|
||||
|
|
@ -809,7 +810,9 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
|||
emit("");
|
||||
}
|
||||
|
||||
// Foreign keys (after all tables and referenced unique constraints are created)
|
||||
// Collect foreign keys now. Emit them after routines and standalone indexes
|
||||
// because PostgreSQL permits a non-constraint unique index to be the target
|
||||
// of a foreign key.
|
||||
const allForeignKeys = await sql<{
|
||||
constraint_name: string;
|
||||
source_schema: string;
|
||||
|
|
@ -849,14 +852,29 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
|||
&& includedTableNames.has(tableKey(fk.target_schema, fk.target_table)),
|
||||
);
|
||||
|
||||
if (fks.length > 0) {
|
||||
emit("-- Foreign keys");
|
||||
for (const fk of fks) {
|
||||
const srcCols = fk.source_columns.map((c) => `"${c}"`).join(", ");
|
||||
const tgtCols = fk.target_columns.map((c) => `"${c}"`).join(", ");
|
||||
emitStatement(
|
||||
`ALTER TABLE ${quoteQualifiedName(fk.source_schema, fk.source_table)} ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES ${quoteQualifiedName(fk.target_schema, fk.target_table)} (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`,
|
||||
);
|
||||
// JavaScript backups are used when a worktree seed filters or transforms
|
||||
// table data. Preserve user-defined routines before indexes because an
|
||||
// expression index may depend on a user-defined function.
|
||||
const routines = await sql<{ definition: string }[]>`
|
||||
SELECT pg_get_functiondef(p.oid) AS definition
|
||||
FROM pg_proc p
|
||||
JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
WHERE ${sql.unsafe(nonSystemSchemaPredicate("n.nspname"))}
|
||||
AND p.prokind IN ('f', 'p')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_depend d
|
||||
WHERE d.classid = 'pg_proc'::regclass
|
||||
AND d.objid = p.oid
|
||||
AND d.deptype = 'e'
|
||||
)
|
||||
ORDER BY n.nspname, p.proname, pg_get_function_identity_arguments(p.oid)
|
||||
`;
|
||||
if (routines.length > 0) {
|
||||
emit("-- Functions and procedures");
|
||||
for (const routine of routines) {
|
||||
const definition = routine.definition.trimEnd();
|
||||
emitStatement(definition.endsWith(";") ? definition : `${definition};`);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
|
@ -883,6 +901,18 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
|||
emit("");
|
||||
}
|
||||
|
||||
if (fks.length > 0) {
|
||||
emit("-- Foreign keys");
|
||||
for (const fk of fks) {
|
||||
const srcCols = fk.source_columns.map((c) => `"${c}"`).join(", ");
|
||||
const tgtCols = fk.target_columns.map((c) => `"${c}"`).join(", ");
|
||||
emitStatement(
|
||||
`ALTER TABLE ${quoteQualifiedName(fk.source_schema, fk.source_table)} ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES ${quoteQualifiedName(fk.target_schema, fk.target_table)} (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`,
|
||||
);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
// Dump data for each table
|
||||
for (const { schema_name, tablename } of tables) {
|
||||
const currentTableKey = tableKey(schema_name, tablename);
|
||||
|
|
@ -938,6 +968,33 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
|||
emit("");
|
||||
}
|
||||
|
||||
const allTriggers = await sql<{
|
||||
schema_name: string;
|
||||
tablename: string;
|
||||
definition: string;
|
||||
}[]>`
|
||||
SELECT
|
||||
n.nspname AS schema_name,
|
||||
c.relname AS tablename,
|
||||
pg_get_triggerdef(t.oid, true) AS definition
|
||||
FROM pg_trigger t
|
||||
JOIN pg_class c ON c.oid = t.tgrelid
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE NOT t.tgisinternal
|
||||
AND ${sql.unsafe(nonSystemSchemaPredicate("n.nspname"))}
|
||||
ORDER BY n.nspname, c.relname, t.tgname
|
||||
`;
|
||||
const triggers = allTriggers.filter((entry) => (
|
||||
includedTableNames.has(tableKey(entry.schema_name, entry.tablename))
|
||||
));
|
||||
if (triggers.length > 0) {
|
||||
emit("-- Triggers");
|
||||
for (const trigger of triggers) {
|
||||
emitStatement(`${trigger.definition};`);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
// Sequence values
|
||||
if (sequences.length > 0) {
|
||||
emit("-- Sequence values");
|
||||
|
|
|
|||
|
|
@ -1405,4 +1405,466 @@ describeEmbeddedPostgres("applyPendingMigrations", () => {
|
|||
},
|
||||
20_000,
|
||||
);
|
||||
|
||||
it(
|
||||
"preserves legacy runs while adding native persistence and replay-safe status versioning",
|
||||
async () => {
|
||||
const connectionString = await createTempDatabase();
|
||||
await applyPendingMigrations(connectionString);
|
||||
|
||||
const nativePersistenceHash = await migrationHash("0227_modern_pandemic.sql");
|
||||
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
|
||||
const companyId = "10000000-0000-4000-8000-000000000227";
|
||||
const agentId = "20000000-0000-4000-8000-000000000227";
|
||||
const runId = "30000000-0000-4000-8000-000000000227";
|
||||
const issueId = "40000000-0000-4000-8000-000000000227";
|
||||
const contractId = "50000000-0000-4000-8000-000000000227";
|
||||
const resultId = "60000000-0000-4000-8000-000000000227";
|
||||
const assessmentId = "70000000-0000-4000-8000-000000000227";
|
||||
const decisionId = "80000000-0000-4000-8000-000000000227";
|
||||
const otherCompanyId = "11000000-0000-4000-8000-000000000227";
|
||||
const otherAgentId = "21000000-0000-4000-8000-000000000227";
|
||||
const otherRunId = "31000000-0000-4000-8000-000000000227";
|
||||
const otherIssueId = "41000000-0000-4000-8000-000000000227";
|
||||
const otherContractId = "51000000-0000-4000-8000-000000000227";
|
||||
const otherResultId = "61000000-0000-4000-8000-000000000227";
|
||||
const otherAssessmentId = "71000000-0000-4000-8000-000000000227";
|
||||
const otherDecisionId = "81000000-0000-4000-8000-000000000227";
|
||||
|
||||
try {
|
||||
await sql.unsafe(`
|
||||
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 issues_company_id_uq;
|
||||
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 native_issue_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 sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${nativePersistenceHash}`;
|
||||
await sql`
|
||||
INSERT INTO companies (id, name, issue_prefix)
|
||||
VALUES (${companyId}, 'Native persistence fixture', 'NPF')
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO agents (id, company_id, name)
|
||||
VALUES (${agentId}, ${companyId}, 'Legacy migration agent')
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO heartbeat_runs (id, company_id, agent_id, status)
|
||||
VALUES (${runId}, ${companyId}, ${agentId}, 'succeeded')
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO issues (id, company_id, title, status)
|
||||
VALUES (${issueId}, ${companyId}, 'Legacy migration issue', 'in_progress')
|
||||
`;
|
||||
await sql.unsafe(`
|
||||
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', '{"bytes":"alpha-1"}'::jsonb, '2026-08-01T00:00:01.000Z'),
|
||||
('${companyId}', '${runId}', '${agentId}', 5, 'legacy.log', 'stdout', 'info', 'first-five', '{"bytes":"beta-5a"}'::jsonb, '2026-08-01T00:00:02.000Z'),
|
||||
('${companyId}', '${runId}', '${agentId}', 5, 'legacy.log', 'stderr', 'warn', 'duplicate-five', '{"bytes":"gamma-5b"}'::jsonb, '2026-08-01T00:00:03.000Z'),
|
||||
('${companyId}', '${runId}', '${agentId}', 9, 'legacy.end', 'system', 'info', 'nine', '{"bytes":"delta-9"}'::jsonb, '2026-08-01T00:00:04.000Z')
|
||||
`);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
|
||||
await applyPendingMigrations(connectionString);
|
||||
|
||||
const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const events = await verifySql.unsafe<{
|
||||
seq: string;
|
||||
event_type: string;
|
||||
stream: string;
|
||||
level: string;
|
||||
message: string;
|
||||
payload: { bytes: string };
|
||||
created_at: Date;
|
||||
}[]>(`
|
||||
SELECT seq, event_type, stream, level, message, payload, created_at
|
||||
FROM heartbeat_run_events
|
||||
WHERE run_id = '${runId}'
|
||||
ORDER BY id
|
||||
`);
|
||||
expect(events.map((event) => Number(event.seq))).toEqual([1, 5, 5, 9]);
|
||||
expect(events.map(({ seq: _seq, ...event }) => ({
|
||||
...event,
|
||||
created_at: event.created_at.toISOString(),
|
||||
}))).toEqual([
|
||||
{
|
||||
event_type: "legacy.start",
|
||||
stream: "system",
|
||||
level: "info",
|
||||
message: "one",
|
||||
payload: { bytes: "alpha-1" },
|
||||
created_at: "2026-08-01T00:00:01.000Z",
|
||||
},
|
||||
{
|
||||
event_type: "legacy.log",
|
||||
stream: "stdout",
|
||||
level: "info",
|
||||
message: "first-five",
|
||||
payload: { bytes: "beta-5a" },
|
||||
created_at: "2026-08-01T00:00:02.000Z",
|
||||
},
|
||||
{
|
||||
event_type: "legacy.log",
|
||||
stream: "stderr",
|
||||
level: "warn",
|
||||
message: "duplicate-five",
|
||||
payload: { bytes: "gamma-5b" },
|
||||
created_at: "2026-08-01T00:00:03.000Z",
|
||||
},
|
||||
{
|
||||
event_type: "legacy.end",
|
||||
stream: "system",
|
||||
level: "info",
|
||||
message: "nine",
|
||||
payload: { bytes: "delta-9" },
|
||||
created_at: "2026-08-01T00:00:04.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
const runs = await verifySql.unsafe<{ runtime_mode: string; next_event_seq: string }[]>(`
|
||||
SELECT runtime_mode, next_event_seq
|
||||
FROM heartbeat_runs
|
||||
WHERE id = '${runId}'
|
||||
`);
|
||||
expect(runs.map((run) => ({
|
||||
runtimeMode: run.runtime_mode,
|
||||
nextEventSeq: Number(run.next_event_seq),
|
||||
}))).toEqual([{ runtimeMode: "legacy", nextEventSeq: 10 }]);
|
||||
|
||||
const nativeRowsBefore = await verifySql.unsafe<{ table_name: string; row_count: number }[]>(`
|
||||
SELECT 'completion_contracts' AS table_name, count(*)::int AS row_count FROM completion_contracts
|
||||
UNION ALL SELECT 'native_run_results', count(*)::int FROM native_run_results
|
||||
UNION ALL SELECT 'native_run_finalizations', count(*)::int FROM native_run_finalizations
|
||||
UNION ALL SELECT 'work_assessments', count(*)::int FROM work_assessments
|
||||
UNION ALL SELECT 'status_decisions', count(*)::int FROM status_decisions
|
||||
UNION ALL SELECT 'status_decision_effects', count(*)::int FROM status_decision_effects
|
||||
ORDER BY table_name
|
||||
`);
|
||||
expect(nativeRowsBefore.every((row) => row.row_count === 0)).toBe(true);
|
||||
|
||||
await verifySql`
|
||||
INSERT INTO companies (id, name, issue_prefix)
|
||||
VALUES (${otherCompanyId}, 'Other native persistence fixture', 'ONP')
|
||||
`;
|
||||
await verifySql`
|
||||
INSERT INTO agents (id, company_id, name)
|
||||
VALUES (${otherAgentId}, ${otherCompanyId}, 'Other native migration agent')
|
||||
`;
|
||||
await verifySql`
|
||||
INSERT INTO heartbeat_runs (
|
||||
id, company_id, agent_id, status, native_issue_id, completion_contract_id
|
||||
) VALUES (
|
||||
${otherRunId}, ${otherCompanyId}, ${otherAgentId}, 'succeeded',
|
||||
${otherIssueId}, ${otherContractId}
|
||||
)
|
||||
`;
|
||||
await verifySql`
|
||||
INSERT INTO issues (id, company_id, title, status)
|
||||
VALUES (${otherIssueId}, ${otherCompanyId}, 'Other native issue', 'in_progress')
|
||||
`;
|
||||
await verifySql`
|
||||
UPDATE heartbeat_runs
|
||||
SET native_issue_id = ${issueId}, completion_contract_id = ${contractId}
|
||||
WHERE id = ${runId}
|
||||
`;
|
||||
|
||||
await expect(verifySql`
|
||||
INSERT INTO completion_contracts (
|
||||
company_id, issue_id, revision, schema_version, policy_version,
|
||||
risk, completion_authority, incomplete_criteria_policy, contract_json,
|
||||
canonical_sha256, created_by_actor_type, created_by_actor_id
|
||||
) VALUES (
|
||||
${companyId}, ${otherIssueId}, 1, 'paperclip.completion-contract.v1',
|
||||
'policy-v1', 'low', 'server', 'review', ${JSON.stringify({ criteria: [] })}::jsonb,
|
||||
'cross-company-contract-sha', 'system', 'migration-test'
|
||||
)
|
||||
`).rejects.toThrow(/completion_contracts_issue_company_fk/);
|
||||
|
||||
await verifySql`
|
||||
INSERT INTO completion_contracts (
|
||||
id, company_id, issue_id, revision, schema_version, policy_version,
|
||||
risk, completion_authority, incomplete_criteria_policy, contract_json,
|
||||
canonical_sha256, created_by_actor_type, created_by_actor_id
|
||||
) VALUES (
|
||||
${contractId}, ${companyId}, ${issueId}, 1, 'paperclip.completion-contract.v1',
|
||||
'policy-v1', 'low', 'server', 'review', ${JSON.stringify({ criteria: [] })}::jsonb,
|
||||
'contract-sha', 'system', 'migration-test'
|
||||
)
|
||||
`;
|
||||
await verifySql`
|
||||
INSERT INTO completion_contracts (
|
||||
id, company_id, issue_id, revision, schema_version, policy_version,
|
||||
risk, completion_authority, incomplete_criteria_policy, contract_json,
|
||||
canonical_sha256, created_by_actor_type, created_by_actor_id
|
||||
) VALUES (
|
||||
${otherContractId}, ${otherCompanyId}, ${otherIssueId}, 1,
|
||||
'paperclip.completion-contract.v1', 'policy-v1', 'low', 'server', 'review',
|
||||
${JSON.stringify({ criteria: [] })}::jsonb, 'other-contract-sha',
|
||||
'system', 'migration-test'
|
||||
)
|
||||
`;
|
||||
|
||||
await expect(verifySql`
|
||||
INSERT INTO native_run_results (
|
||||
company_id, issue_id, run_id, completion_contract_id,
|
||||
server_fingerprint, schema_status, result_json, canonical_sha256
|
||||
) VALUES (
|
||||
${companyId}, ${issueId}, ${otherRunId}, ${contractId},
|
||||
'cross-company-run', 'valid', ${JSON.stringify({ summary: "invalid" })}::jsonb,
|
||||
'cross-company-run-sha'
|
||||
)
|
||||
`).rejects.toThrow(/native_run_results_run_contract_owner_fk/);
|
||||
|
||||
await expect(verifySql`
|
||||
INSERT INTO native_run_results (
|
||||
company_id, issue_id, run_id, completion_contract_id,
|
||||
server_fingerprint, schema_status, result_json, canonical_sha256
|
||||
) VALUES (
|
||||
${companyId}, ${issueId}, ${runId}, ${otherContractId},
|
||||
'cross-company-contract', 'valid', ${JSON.stringify({ summary: "invalid" })}::jsonb,
|
||||
'cross-company-contract-sha'
|
||||
)
|
||||
`).rejects.toThrow(/native_run_results_run_contract_owner_fk/);
|
||||
|
||||
await verifySql`
|
||||
INSERT INTO native_run_results (
|
||||
id, company_id, issue_id, run_id, completion_contract_id,
|
||||
server_fingerprint, schema_status, result_json, canonical_sha256
|
||||
) VALUES (
|
||||
${resultId}, ${companyId}, ${issueId}, ${runId}, ${contractId},
|
||||
'result-fingerprint', 'valid', ${JSON.stringify({ summary: "done" })}::jsonb, 'result-sha'
|
||||
)
|
||||
`;
|
||||
await verifySql`
|
||||
INSERT INTO native_run_results (
|
||||
id, company_id, issue_id, run_id, completion_contract_id,
|
||||
server_fingerprint, schema_status, result_json, canonical_sha256
|
||||
) VALUES (
|
||||
${otherResultId}, ${otherCompanyId}, ${otherIssueId}, ${otherRunId},
|
||||
${otherContractId}, 'other-result-fingerprint', 'valid',
|
||||
${JSON.stringify({ summary: "other" })}::jsonb, 'other-result-sha'
|
||||
)
|
||||
`;
|
||||
|
||||
await expect(verifySql`
|
||||
INSERT INTO work_assessments (
|
||||
company_id, issue_id, run_id, contract_id, result_id,
|
||||
trigger_kind, trigger_actor_company_id, prior_issue_status,
|
||||
prior_status_version, policy_version, assessment_json, input_digest
|
||||
) VALUES (
|
||||
${companyId}, ${issueId}, ${runId}, ${contractId}, ${otherResultId},
|
||||
'run_terminal', ${companyId}, 'in_progress', 0, 'policy-v1',
|
||||
${JSON.stringify({ disposition: "invalid" })}::jsonb,
|
||||
'cross-company-assessment-input-sha'
|
||||
)
|
||||
`).rejects.toThrow(/work_assessments_result_owner_fk/);
|
||||
|
||||
await expect(verifySql`
|
||||
INSERT INTO work_assessments (
|
||||
company_id, issue_id, run_id, contract_id, result_id,
|
||||
trigger_kind, trigger_actor_company_id, prior_issue_status,
|
||||
prior_status_version, policy_version, assessment_json, input_digest
|
||||
) VALUES (
|
||||
${companyId}, ${issueId}, ${runId}, ${contractId}, ${resultId},
|
||||
'run_terminal', ${otherCompanyId}, 'in_progress', 0, 'policy-v1',
|
||||
${JSON.stringify({ disposition: "invalid" })}::jsonb,
|
||||
'cross-company-trigger-input-sha'
|
||||
)
|
||||
`).rejects.toThrow(/work_assessments_trigger_actor_company_check/);
|
||||
|
||||
await verifySql`
|
||||
INSERT INTO work_assessments (
|
||||
id, company_id, issue_id, run_id, contract_id, result_id,
|
||||
trigger_kind, trigger_actor_company_id, prior_issue_status,
|
||||
prior_status_version, policy_version, assessment_json, input_digest
|
||||
) VALUES (
|
||||
${assessmentId}, ${companyId}, ${issueId}, ${runId}, ${contractId}, ${resultId},
|
||||
'run_terminal', ${companyId}, 'in_progress', 0, 'policy-v1',
|
||||
${JSON.stringify({ disposition: "done" })}::jsonb, 'assessment-input-sha'
|
||||
)
|
||||
`;
|
||||
await verifySql`
|
||||
INSERT INTO work_assessments (
|
||||
id, company_id, issue_id, run_id, contract_id, result_id,
|
||||
trigger_kind, trigger_actor_company_id, prior_issue_status,
|
||||
prior_status_version, policy_version, assessment_json, input_digest
|
||||
) VALUES (
|
||||
${otherAssessmentId}, ${otherCompanyId}, ${otherIssueId}, ${otherRunId},
|
||||
${otherContractId}, ${otherResultId}, 'run_terminal', ${otherCompanyId},
|
||||
'in_progress', 0, 'policy-v1',
|
||||
${JSON.stringify({ disposition: "done" })}::jsonb, 'other-assessment-input-sha'
|
||||
)
|
||||
`;
|
||||
|
||||
await expect(verifySql`
|
||||
INSERT INTO status_decisions (
|
||||
company_id, issue_id, run_id, assessment_id, decision_version,
|
||||
policy_version, from_status, to_status, reason_code,
|
||||
decision_json, decision_digest
|
||||
) VALUES (
|
||||
${companyId}, ${issueId}, ${runId}, ${otherAssessmentId}, 1,
|
||||
'policy-v1', 'in_progress', 'done', 'native_result_accepted',
|
||||
${JSON.stringify({ toStatus: "done" })}::jsonb, 'cross-company-decision-sha'
|
||||
)
|
||||
`).rejects.toThrow(/status_decisions_assessment_owner_fk/);
|
||||
|
||||
await verifySql`
|
||||
INSERT INTO status_decisions (
|
||||
id, company_id, issue_id, run_id, assessment_id, decision_version,
|
||||
policy_version, from_status, to_status, reason_code,
|
||||
decision_json, decision_digest
|
||||
) VALUES (
|
||||
${decisionId}, ${companyId}, ${issueId}, ${runId}, ${assessmentId}, 1,
|
||||
'policy-v1', 'in_progress', 'done', 'native_result_accepted',
|
||||
${JSON.stringify({ toStatus: "done" })}::jsonb, 'decision-sha'
|
||||
)
|
||||
`;
|
||||
await verifySql`
|
||||
INSERT INTO status_decisions (
|
||||
id, company_id, issue_id, run_id, assessment_id, decision_version,
|
||||
policy_version, from_status, to_status, reason_code,
|
||||
decision_json, decision_digest
|
||||
) VALUES (
|
||||
${otherDecisionId}, ${otherCompanyId}, ${otherIssueId}, ${otherRunId},
|
||||
${otherAssessmentId}, 1, 'policy-v1', 'in_progress', 'done',
|
||||
'native_result_accepted', ${JSON.stringify({ toStatus: "done" })}::jsonb,
|
||||
'other-decision-sha'
|
||||
)
|
||||
`;
|
||||
|
||||
await expect(verifySql`
|
||||
INSERT INTO status_decision_effects (
|
||||
company_id, issue_id, decision_id, ordinal, effect_kind,
|
||||
target_type, idempotency_key, payload
|
||||
) VALUES (
|
||||
${companyId}, ${issueId}, ${otherDecisionId}, 0, 'update_issue_status',
|
||||
'issue', 'cross-company-decision-effect', ${JSON.stringify({ status: "done" })}::jsonb
|
||||
)
|
||||
`).rejects.toThrow(/status_decision_effects_decision_owner_fk/);
|
||||
|
||||
await verifySql`
|
||||
INSERT INTO status_decision_effects (
|
||||
company_id, issue_id, decision_id, ordinal, effect_kind,
|
||||
target_type, idempotency_key, payload
|
||||
) VALUES (
|
||||
${companyId}, ${issueId}, ${decisionId}, 0, 'update_issue_status',
|
||||
'issue', 'decision-effect-1', ${JSON.stringify({ status: "done" })}::jsonb
|
||||
)
|
||||
`;
|
||||
|
||||
await expect(verifySql`
|
||||
INSERT INTO native_run_finalizations (
|
||||
run_id, company_id, issue_id, phase, result_id, assessment_id, decision_id
|
||||
) VALUES (
|
||||
${runId}, ${companyId}, ${issueId}, 'committed', ${resultId},
|
||||
${assessmentId}, ${otherDecisionId}
|
||||
)
|
||||
`).rejects.toThrow(/native_run_finalizations_decision_owner_fk/);
|
||||
|
||||
await verifySql`
|
||||
INSERT INTO native_run_finalizations (
|
||||
run_id, company_id, issue_id, phase, result_id, assessment_id, decision_id
|
||||
) VALUES (
|
||||
${runId}, ${companyId}, ${issueId}, 'committed', ${resultId}, ${assessmentId}, ${decisionId}
|
||||
)
|
||||
`;
|
||||
|
||||
await verifySql`UPDATE issues SET title = 'Renamed legacy issue' WHERE id = ${issueId}`;
|
||||
await verifySql`UPDATE issues SET status = 'done' WHERE id = ${issueId}`;
|
||||
const issues = await verifySql.unsafe<{ status: string; status_version: string }[]>(`
|
||||
SELECT status, status_version
|
||||
FROM issues
|
||||
WHERE id = '${issueId}'
|
||||
`);
|
||||
expect(issues.map((issue) => ({
|
||||
status: issue.status,
|
||||
statusVersion: Number(issue.status_version),
|
||||
}))).toEqual([{ status: "done", statusVersion: 1 }]);
|
||||
|
||||
await verifySql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${nativePersistenceHash}`;
|
||||
} finally {
|
||||
await verifySql.end();
|
||||
}
|
||||
|
||||
await expect(applyPendingMigrations(connectionString)).resolves.toBeUndefined();
|
||||
await expect(inspectMigrations(connectionString)).resolves.toMatchObject({
|
||||
status: "upToDate",
|
||||
});
|
||||
|
||||
const replaySql = postgres(connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const replayed = await replaySql.unsafe<{
|
||||
status_version: string;
|
||||
next_event_seq: string;
|
||||
trigger_count: number;
|
||||
finalization_count: number;
|
||||
}[]>(`
|
||||
SELECT
|
||||
issue.status_version,
|
||||
run.next_event_seq,
|
||||
(
|
||||
SELECT count(*)::int
|
||||
FROM pg_trigger
|
||||
WHERE tgname = 'paperclip_issue_status_version_trigger'
|
||||
AND NOT tgisinternal
|
||||
) AS trigger_count,
|
||||
(
|
||||
SELECT count(*)::int
|
||||
FROM native_run_finalizations
|
||||
WHERE run_id = '${runId}'
|
||||
) AS finalization_count
|
||||
FROM issues issue
|
||||
CROSS JOIN heartbeat_runs run
|
||||
WHERE issue.id = '${issueId}' AND run.id = '${runId}'
|
||||
`);
|
||||
expect(replayed.map((row) => ({
|
||||
statusVersion: Number(row.status_version),
|
||||
nextEventSeq: Number(row.next_event_seq),
|
||||
triggerCount: row.trigger_count,
|
||||
finalizationCount: row.finalization_count,
|
||||
}))).toEqual([{
|
||||
statusVersion: 1,
|
||||
nextEventSeq: 10,
|
||||
triggerCount: 1,
|
||||
finalizationCount: 1,
|
||||
}]);
|
||||
} finally {
|
||||
await replaySql.end();
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -411,6 +411,25 @@ async function columnExists(
|
|||
return rows[0]?.exists ?? false;
|
||||
}
|
||||
|
||||
async function columnHasDataType(
|
||||
sql: ReturnType<typeof postgres>,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
dataType: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await sql<{ dataType: string; udtName: string }[]>`
|
||||
SELECT data_type AS "dataType", udt_name AS "udtName"
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = ${tableName}
|
||||
AND column_name = ${columnName}
|
||||
`;
|
||||
const expected = dataType.toLowerCase();
|
||||
return rows.some((row) => (
|
||||
row.dataType.toLowerCase() === expected || row.udtName.toLowerCase() === expected
|
||||
));
|
||||
}
|
||||
|
||||
async function indexExists(
|
||||
sql: ReturnType<typeof postgres>,
|
||||
indexName: string,
|
||||
|
|
@ -444,11 +463,65 @@ async function constraintExists(
|
|||
return rows[0]?.exists ?? false;
|
||||
}
|
||||
|
||||
async function functionExists(
|
||||
sql: ReturnType<typeof postgres>,
|
||||
functionName: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await sql<{ exists: boolean }[]>`
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_proc p
|
||||
JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND p.proname = ${functionName}
|
||||
) AS exists
|
||||
`;
|
||||
return rows[0]?.exists ?? false;
|
||||
}
|
||||
|
||||
async function triggerExists(
|
||||
sql: ReturnType<typeof postgres>,
|
||||
triggerName: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await sql<{ exists: boolean }[]>`
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_trigger t
|
||||
JOIN pg_class c ON c.oid = t.tgrelid
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND t.tgname = ${triggerName}
|
||||
AND NOT t.tgisinternal
|
||||
) AS exists
|
||||
`;
|
||||
return rows[0]?.exists ?? false;
|
||||
}
|
||||
|
||||
async function heartbeatNextEventSequencesAreCurrent(
|
||||
sql: ReturnType<typeof postgres>,
|
||||
): Promise<boolean> {
|
||||
const rows = await sql<{ current: boolean }[]>`
|
||||
SELECT NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM heartbeat_runs run
|
||||
WHERE run.next_event_seq IS DISTINCT FROM COALESCE((
|
||||
SELECT max(event.seq) + 1
|
||||
FROM heartbeat_run_events event
|
||||
WHERE event.run_id = run.id
|
||||
), 1)
|
||||
) AS current
|
||||
`;
|
||||
return rows[0]?.current ?? false;
|
||||
}
|
||||
|
||||
async function migrationStatementAlreadyApplied(
|
||||
sql: ReturnType<typeof postgres>,
|
||||
statement: string,
|
||||
): Promise<boolean> {
|
||||
const normalized = statement.replace(/\s+/g, " ").trim();
|
||||
const normalized = statement
|
||||
.replace(/^\s*--.*$/gm, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
const createTableMatch = normalized.match(/^CREATE TABLE(?: IF NOT EXISTS)? "([^"]+)"/i);
|
||||
if (createTableMatch) {
|
||||
|
|
@ -462,6 +535,18 @@ async function migrationStatementAlreadyApplied(
|
|||
return columnExists(sql, addColumnMatch[1], addColumnMatch[2]);
|
||||
}
|
||||
|
||||
const alterColumnTypeMatch = normalized.match(
|
||||
/^ALTER TABLE "([^"]+)" ALTER COLUMN "([^"]+)" SET DATA TYPE ([A-Za-z0-9_]+)/i,
|
||||
);
|
||||
if (alterColumnTypeMatch) {
|
||||
return columnHasDataType(
|
||||
sql,
|
||||
alterColumnTypeMatch[1],
|
||||
alterColumnTypeMatch[2],
|
||||
alterColumnTypeMatch[3],
|
||||
);
|
||||
}
|
||||
|
||||
const createIndexMatch = normalized.match(/^CREATE (?:UNIQUE )?INDEX(?: IF NOT EXISTS)? "([^"]+)"/i);
|
||||
if (createIndexMatch) {
|
||||
return indexExists(sql, createIndexMatch[1]);
|
||||
|
|
@ -472,6 +557,30 @@ async function migrationStatementAlreadyApplied(
|
|||
return constraintExists(sql, addConstraintMatch[2]);
|
||||
}
|
||||
|
||||
const createFunctionMatch = normalized.match(
|
||||
/^CREATE OR REPLACE FUNCTION "?([A-Za-z_][A-Za-z0-9_]*)"?\s*\(/i,
|
||||
);
|
||||
if (createFunctionMatch) {
|
||||
return functionExists(sql, createFunctionMatch[1]);
|
||||
}
|
||||
|
||||
const createTriggerMatch = normalized.match(
|
||||
/^CREATE TRIGGER "?([A-Za-z_][A-Za-z0-9_]*)"?/i,
|
||||
);
|
||||
if (createTriggerMatch) {
|
||||
return triggerExists(sql, createTriggerMatch[1]);
|
||||
}
|
||||
|
||||
// This native-runner cursor backfill has a persistent postcondition. Verify it
|
||||
// instead of replaying it when a restored database is missing only the
|
||||
// migration-history row.
|
||||
if (
|
||||
normalized.startsWith('UPDATE "heartbeat_runs" AS run')
|
||||
&& normalized.includes('SET "next_event_seq" = COALESCE')
|
||||
) {
|
||||
return heartbeatNextEventSequencesAreCurrent(sql);
|
||||
}
|
||||
|
||||
// If we cannot reason about a statement safely, require manual migration.
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,410 @@
|
|||
CREATE TABLE IF NOT EXISTS "completion_contracts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"issue_id" uuid NOT NULL,
|
||||
"revision" integer NOT NULL,
|
||||
"schema_version" text NOT NULL,
|
||||
"policy_version" text NOT NULL,
|
||||
"risk" text NOT NULL,
|
||||
"completion_authority" text NOT NULL,
|
||||
"incomplete_criteria_policy" text NOT NULL,
|
||||
"contract_json" jsonb NOT NULL,
|
||||
"canonical_sha256" text NOT NULL,
|
||||
"created_by_actor_type" text NOT NULL,
|
||||
"created_by_actor_id" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"supersedes_contract_id" uuid
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "native_run_finalizations" (
|
||||
"run_id" uuid PRIMARY KEY NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"issue_id" uuid NOT NULL,
|
||||
"phase" text NOT NULL,
|
||||
"attempt" integer DEFAULT 0 NOT NULL,
|
||||
"lease_owner" text,
|
||||
"lease_expires_at" timestamp with time zone,
|
||||
"result_id" uuid,
|
||||
"assessment_id" uuid,
|
||||
"decision_id" uuid,
|
||||
"failure_code" text,
|
||||
"failure_detail" jsonb,
|
||||
"next_attempt_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "native_run_results" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"issue_id" uuid NOT NULL,
|
||||
"run_id" uuid NOT NULL,
|
||||
"turn_id" text,
|
||||
"completion_contract_id" uuid NOT NULL,
|
||||
"caller_result_id" text,
|
||||
"caller_dedupe_key" text,
|
||||
"server_fingerprint" text NOT NULL,
|
||||
"schema_status" text NOT NULL,
|
||||
"rejection_code" text,
|
||||
"result_json" jsonb NOT NULL,
|
||||
"canonical_sha256" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "status_decision_effects" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"issue_id" uuid NOT NULL,
|
||||
"decision_id" uuid NOT NULL,
|
||||
"ordinal" integer NOT NULL,
|
||||
"effect_kind" text NOT NULL,
|
||||
"target_type" text NOT NULL,
|
||||
"target_id" text,
|
||||
"idempotency_key" text NOT NULL,
|
||||
"payload" jsonb NOT NULL,
|
||||
"delivery_state" text DEFAULT 'pending' NOT NULL,
|
||||
"attempt_count" integer DEFAULT 0 NOT NULL,
|
||||
"next_attempt_at" timestamp with time zone,
|
||||
"last_error" text,
|
||||
"delivered_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "status_decisions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"issue_id" uuid NOT NULL,
|
||||
"run_id" uuid NOT NULL,
|
||||
"assessment_id" uuid NOT NULL,
|
||||
"decision_version" bigint NOT NULL,
|
||||
"policy_version" text NOT NULL,
|
||||
"from_status" text NOT NULL,
|
||||
"to_status" text NOT NULL,
|
||||
"reason_code" text NOT NULL,
|
||||
"decision_json" jsonb NOT NULL,
|
||||
"decision_digest" text NOT NULL,
|
||||
"application_state" text DEFAULT 'proposed' NOT NULL,
|
||||
"supersedes_decision_id" uuid,
|
||||
"applied_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "work_assessments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"issue_id" uuid NOT NULL,
|
||||
"run_id" uuid NOT NULL,
|
||||
"turn_id" text,
|
||||
"contract_id" uuid NOT NULL,
|
||||
"result_id" uuid NOT NULL,
|
||||
"trigger_kind" text NOT NULL,
|
||||
"trigger_ref" text,
|
||||
"trigger_capability" text,
|
||||
"trigger_actor_company_id" uuid NOT NULL,
|
||||
"prior_issue_status" text NOT NULL,
|
||||
"prior_status_version" bigint NOT NULL,
|
||||
"prior_decision_id" uuid,
|
||||
"policy_version" text NOT NULL,
|
||||
"assessment_json" jsonb NOT NULL,
|
||||
"input_digest" text NOT NULL,
|
||||
"supersedes_assessment_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_run_events" ALTER COLUMN "seq" SET DATA TYPE bigint;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_run_events" ADD COLUMN IF NOT EXISTS "source_instance_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_run_events" ADD COLUMN IF NOT EXISTS "source_event_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_run_events" ADD COLUMN IF NOT EXISTS "source_seq" bigint;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_run_events" ADD COLUMN IF NOT EXISTS "source_payload_sha256" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_run_events" ADD COLUMN IF NOT EXISTS "protocol_schema_version" integer;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "runtime_mode" text DEFAULT 'legacy' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "runtime_mode_resolver_version" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "runtime_mode_reason" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "runtime_mode_resolved_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "runner_profile_json" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "runner_instance_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "native_session_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "native_issue_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "driver_kind" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "driver_version" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "completion_contract_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "completion_contract_sha256" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "next_event_seq" bigint DEFAULT 1 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "native_phase" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "native_phase_updated_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "status_decisions" ADD COLUMN IF NOT EXISTS "run_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "status_decisions" ALTER COLUMN "run_id" SET NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "status_version" bigint DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "last_status_decision_id" uuid;--> statement-breakpoint
|
||||
UPDATE "heartbeat_runs" AS run
|
||||
SET "next_event_seq" = COALESCE((
|
||||
SELECT max(event."seq") + 1
|
||||
FROM "heartbeat_run_events" AS event
|
||||
WHERE event."run_id" = run."id"
|
||||
), 1);--> statement-breakpoint
|
||||
CREATE OR REPLACE FUNCTION paperclip_bump_issue_status_version()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW."status" IS DISTINCT FROM OLD."status" THEN
|
||||
NEW."status_version" := OLD."status_version" + 1;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS paperclip_issue_status_version_trigger ON "issues";--> statement-breakpoint
|
||||
CREATE TRIGGER paperclip_issue_status_version_trigger
|
||||
BEFORE UPDATE OF "status" ON "issues"
|
||||
FOR EACH ROW EXECUTE FUNCTION paperclip_bump_issue_status_version();--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "completion_contracts" ADD CONSTRAINT "completion_contracts_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "completion_contracts" ADD CONSTRAINT "completion_contracts_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_result_id_native_run_results_id_fk" FOREIGN KEY ("result_id") REFERENCES "public"."native_run_results"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "native_run_results" ADD CONSTRAINT "native_run_results_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "native_run_results" ADD CONSTRAINT "native_run_results_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "native_run_results" ADD CONSTRAINT "native_run_results_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "native_run_results" ADD CONSTRAINT "native_run_results_completion_contract_id_completion_contracts_id_fk" FOREIGN KEY ("completion_contract_id") REFERENCES "public"."completion_contracts"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "status_decision_effects" ADD CONSTRAINT "status_decision_effects_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "status_decision_effects" ADD CONSTRAINT "status_decision_effects_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "status_decision_effects" ADD CONSTRAINT "status_decision_effects_decision_id_status_decisions_id_fk" FOREIGN KEY ("decision_id") REFERENCES "public"."status_decisions"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "status_decisions" ADD CONSTRAINT "status_decisions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "status_decisions" ADD CONSTRAINT "status_decisions_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "status_decisions" ADD CONSTRAINT "status_decisions_assessment_id_work_assessments_id_fk" FOREIGN KEY ("assessment_id") REFERENCES "public"."work_assessments"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_contract_id_completion_contracts_id_fk" FOREIGN KEY ("contract_id") REFERENCES "public"."completion_contracts"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_result_id_native_run_results_id_fk" FOREIGN KEY ("result_id") REFERENCES "public"."native_run_results"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_trigger_actor_company_id_companies_id_fk" FOREIGN KEY ("trigger_actor_company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "completion_contracts_issue_revision_uq" ON "completion_contracts" USING btree ("issue_id","revision");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "completion_contracts_issue_hash_uq" ON "completion_contracts" USING btree ("issue_id","canonical_sha256");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "native_run_results_run_fingerprint_uq" ON "native_run_results" USING btree ("run_id","server_fingerprint");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "native_run_results_run_caller_result_uq" ON "native_run_results" USING btree ("run_id","caller_result_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "native_run_results_run_caller_dedupe_uq" ON "native_run_results" USING btree ("run_id","caller_dedupe_key");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "status_decision_effects_decision_ordinal_uq" ON "status_decision_effects" USING btree ("decision_id","ordinal");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "status_decision_effects_company_idempotency_uq" ON "status_decision_effects" USING btree ("company_id","idempotency_key");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "status_decisions_company_issue_version_uq" ON "status_decisions" USING btree ("company_id","issue_id","decision_version");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "status_decisions_company_assessment_uq" ON "status_decisions" USING btree ("company_id","assessment_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "status_decisions_company_issue_digest_uq" ON "status_decisions" USING btree ("company_id","issue_id","decision_digest");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "work_assessments_company_issue_input_uq" ON "work_assessments" USING btree ("company_id","issue_id","input_digest");--> statement-breakpoint
|
||||
-- paperclip:migration-safety-ignore large-create-index-not-concurrently: nullable native source IDs mean historical rows need no backfill and the invariant must commit atomically with the new columns.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "heartbeat_run_events_run_source_event_uq" ON "heartbeat_run_events" USING btree ("run_id","source_event_id") WHERE "heartbeat_run_events"."source_event_id" is not null;--> statement-breakpoint
|
||||
-- paperclip:migration-safety-ignore large-create-index-not-concurrently: nullable native source sequence fields mean historical rows need no backfill and the invariant must commit atomically with the new columns.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "heartbeat_run_events_run_source_seq_uq" ON "heartbeat_run_events" USING btree ("run_id","source_instance_id","source_seq") WHERE "heartbeat_run_events"."source_instance_id" is not null and "heartbeat_run_events"."source_seq" is not null;
|
||||
--> statement-breakpoint
|
||||
-- paperclip:migration-safety-ignore large-create-index-not-concurrently: the primary key already makes this ownership tuple unique; this supporting index only enables composite foreign keys.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "issues_company_id_uq" ON "issues" USING btree ("company_id","id");--> statement-breakpoint
|
||||
-- paperclip:migration-safety-ignore large-create-index-not-concurrently: the primary key already makes this ownership tuple unique; this supporting index only enables composite foreign keys.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "heartbeat_runs_company_native_issue_id_uq" ON "heartbeat_runs" USING btree ("company_id","native_issue_id","id");--> statement-breakpoint
|
||||
-- paperclip:migration-safety-ignore large-create-index-not-concurrently: the primary key already makes this ownership tuple unique; this supporting index only enables composite foreign keys.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "heartbeat_runs_company_native_issue_contract_id_uq" ON "heartbeat_runs" USING btree ("company_id","native_issue_id","id","completion_contract_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "completion_contracts_company_issue_id_uq" ON "completion_contracts" USING btree ("company_id","issue_id","id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "native_run_results_company_issue_run_id_uq" ON "native_run_results" USING btree ("company_id","issue_id","run_id","id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "work_assessments_company_issue_run_id_uq" ON "work_assessments" USING btree ("company_id","issue_id","run_id","id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "status_decisions_company_issue_id_uq" ON "status_decisions" USING btree ("company_id","issue_id","id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "status_decisions_company_issue_run_assessment_id_uq" ON "status_decisions" USING btree ("company_id","issue_id","run_id","assessment_id","id");--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "completion_contracts" ADD CONSTRAINT "completion_contracts_issue_company_fk" FOREIGN KEY ("company_id","issue_id") REFERENCES "public"."issues"("company_id","id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "completion_contracts" ADD CONSTRAINT "completion_contracts_supersedes_owner_fk" FOREIGN KEY ("company_id","issue_id","supersedes_contract_id") REFERENCES "public"."completion_contracts"("company_id","issue_id","id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "native_run_results" ADD CONSTRAINT "native_run_results_issue_company_fk" FOREIGN KEY ("company_id","issue_id") REFERENCES "public"."issues"("company_id","id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "native_run_results" ADD CONSTRAINT "native_run_results_run_contract_owner_fk" FOREIGN KEY ("company_id","issue_id","run_id","completion_contract_id") REFERENCES "public"."heartbeat_runs"("company_id","native_issue_id","id","completion_contract_id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "native_run_results" ADD CONSTRAINT "native_run_results_completion_contract_owner_fk" FOREIGN KEY ("company_id","issue_id","completion_contract_id") REFERENCES "public"."completion_contracts"("company_id","issue_id","id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_issue_company_fk" FOREIGN KEY ("company_id","issue_id") REFERENCES "public"."issues"("company_id","id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_run_owner_fk" FOREIGN KEY ("company_id","issue_id","run_id") REFERENCES "public"."heartbeat_runs"("company_id","native_issue_id","id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_contract_owner_fk" FOREIGN KEY ("company_id","issue_id","contract_id") REFERENCES "public"."completion_contracts"("company_id","issue_id","id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_result_owner_fk" FOREIGN KEY ("company_id","issue_id","run_id","result_id") REFERENCES "public"."native_run_results"("company_id","issue_id","run_id","id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_supersedes_owner_fk" FOREIGN KEY ("company_id","issue_id","run_id","supersedes_assessment_id") REFERENCES "public"."work_assessments"("company_id","issue_id","run_id","id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "status_decisions" ADD CONSTRAINT "status_decisions_issue_company_fk" FOREIGN KEY ("company_id","issue_id") REFERENCES "public"."issues"("company_id","id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "status_decisions" ADD CONSTRAINT "status_decisions_assessment_owner_fk" FOREIGN KEY ("company_id","issue_id","run_id","assessment_id") REFERENCES "public"."work_assessments"("company_id","issue_id","run_id","id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "status_decisions" ADD CONSTRAINT "status_decisions_supersedes_owner_fk" FOREIGN KEY ("company_id","issue_id","supersedes_decision_id") REFERENCES "public"."status_decisions"("company_id","issue_id","id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "status_decision_effects" ADD CONSTRAINT "status_decision_effects_issue_company_fk" FOREIGN KEY ("company_id","issue_id") REFERENCES "public"."issues"("company_id","id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "status_decision_effects" ADD CONSTRAINT "status_decision_effects_decision_owner_fk" FOREIGN KEY ("company_id","issue_id","decision_id") REFERENCES "public"."status_decisions"("company_id","issue_id","id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_issue_company_fk" FOREIGN KEY ("company_id","issue_id") REFERENCES "public"."issues"("company_id","id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_run_owner_fk" FOREIGN KEY ("company_id","issue_id","run_id") REFERENCES "public"."heartbeat_runs"("company_id","native_issue_id","id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_result_owner_fk" FOREIGN KEY ("company_id","issue_id","run_id","result_id") REFERENCES "public"."native_run_results"("company_id","issue_id","run_id","id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_assessment_owner_fk" FOREIGN KEY ("company_id","issue_id","run_id","assessment_id") REFERENCES "public"."work_assessments"("company_id","issue_id","run_id","id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_decision_owner_fk" FOREIGN KEY ("company_id","issue_id","run_id","assessment_id","decision_id") REFERENCES "public"."status_decisions"("company_id","issue_id","run_id","assessment_id","id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_assessment_requires_result_check" CHECK ("assessment_id" is null or "result_id" is not null);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_decision_requires_assessment_check" CHECK ("decision_id" is null or "assessment_id" is not null);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_trigger_actor_company_check" CHECK ("trigger_actor_company_id" = "company_id");
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1576,6 +1576,13 @@
|
|||
"when": 1787261199301,
|
||||
"tag": "0226_tan_colossus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 227,
|
||||
"version": "7",
|
||||
"when": 1787668790923,
|
||||
"tag": "0227_modern_pandemic",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import {
|
||||
foreignKey,
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
integer,
|
||||
jsonb,
|
||||
unique,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
|
||||
export const completionContracts = pgTable(
|
||||
"completion_contracts",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
issueId: uuid("issue_id").notNull(),
|
||||
revision: integer("revision").notNull(),
|
||||
schemaVersion: text("schema_version").notNull(),
|
||||
policyVersion: text("policy_version").notNull(),
|
||||
risk: text("risk").notNull(),
|
||||
completionAuthority: text("completion_authority").notNull(),
|
||||
incompleteCriteriaPolicy: text("incomplete_criteria_policy").notNull(),
|
||||
contractJson: jsonb("contract_json").$type<Record<string, unknown>>().notNull(),
|
||||
canonicalSha256: text("canonical_sha256").notNull(),
|
||||
createdByActorType: text("created_by_actor_type").notNull(),
|
||||
createdByActorId: text("created_by_actor_id").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
supersedesContractId: uuid("supersedes_contract_id"),
|
||||
},
|
||||
(table) => ({
|
||||
companyIssueIdUq: unique("completion_contracts_company_issue_id_uq").on(
|
||||
table.companyId,
|
||||
table.issueId,
|
||||
table.id,
|
||||
),
|
||||
issueCompanyFk: foreignKey({
|
||||
columns: [table.companyId, table.issueId],
|
||||
foreignColumns: [issues.companyId, issues.id],
|
||||
name: "completion_contracts_issue_company_fk",
|
||||
}),
|
||||
supersedesOwnerFk: foreignKey({
|
||||
columns: [table.companyId, table.issueId, table.supersedesContractId],
|
||||
foreignColumns: [table.companyId, table.issueId, table.id],
|
||||
name: "completion_contracts_supersedes_owner_fk",
|
||||
}),
|
||||
issueRevisionUq: uniqueIndex("completion_contracts_issue_revision_uq").on(
|
||||
table.issueId,
|
||||
table.revision,
|
||||
),
|
||||
issueHashUq: uniqueIndex("completion_contracts_issue_hash_uq").on(
|
||||
table.issueId,
|
||||
table.canonicalSha256,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,4 +1,16 @@
|
|||
import { pgTable, uuid, text, timestamp, integer, jsonb, index, bigserial } from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
integer,
|
||||
jsonb,
|
||||
index,
|
||||
bigserial,
|
||||
bigint,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { agents } from "./agents.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
|
|
@ -10,19 +22,29 @@ export const heartbeatRunEvents = pgTable(
|
|||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
runId: uuid("run_id").notNull().references(() => heartbeatRuns.id),
|
||||
agentId: uuid("agent_id").notNull().references(() => agents.id),
|
||||
seq: integer("seq").notNull(),
|
||||
seq: bigint("seq", { mode: "number" }).notNull(),
|
||||
eventType: text("event_type").notNull(),
|
||||
stream: text("stream"),
|
||||
level: text("level"),
|
||||
color: text("color"),
|
||||
message: text("message"),
|
||||
payload: jsonb("payload").$type<Record<string, unknown>>(),
|
||||
sourceInstanceId: text("source_instance_id"),
|
||||
sourceEventId: text("source_event_id"),
|
||||
sourceSeq: bigint("source_seq", { mode: "number" }),
|
||||
sourcePayloadSha256: text("source_payload_sha256"),
|
||||
protocolSchemaVersion: integer("protocol_schema_version"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
runSeqIdx: index("heartbeat_run_events_run_seq_idx").on(table.runId, table.seq),
|
||||
runSourceEventUq: uniqueIndex("heartbeat_run_events_run_source_event_uq")
|
||||
.on(table.runId, table.sourceEventId)
|
||||
.where(sql`${table.sourceEventId} is not null`),
|
||||
runSourceSeqUq: uniqueIndex("heartbeat_run_events_run_source_seq_uq")
|
||||
.on(table.runId, table.sourceInstanceId, table.sourceSeq)
|
||||
.where(sql`${table.sourceInstanceId} is not null and ${table.sourceSeq} is not null`),
|
||||
companyRunIdx: index("heartbeat_run_events_company_run_idx").on(table.companyId, table.runId),
|
||||
companyCreatedIdx: index("heartbeat_run_events_company_created_idx").on(table.companyId, table.createdAt),
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
import { sql } from "drizzle-orm";
|
||||
import { type AnyPgColumn, pgTable, uuid, text, timestamp, jsonb, index, integer, bigint, boolean } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
type AnyPgColumn,
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
integer,
|
||||
bigint,
|
||||
boolean,
|
||||
unique,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { agents } from "./agents.js";
|
||||
import { agentWakeupRequests } from "./agent_wakeup_requests.js";
|
||||
|
|
@ -22,6 +34,21 @@ export const heartbeatRuns = pgTable(
|
|||
signal: text("signal"),
|
||||
usageJson: jsonb("usage_json").$type<Record<string, unknown>>(),
|
||||
resultJson: jsonb("result_json").$type<Record<string, unknown>>(),
|
||||
runtimeMode: text("runtime_mode").notNull().default("legacy"),
|
||||
runtimeModeResolverVersion: text("runtime_mode_resolver_version"),
|
||||
runtimeModeReason: text("runtime_mode_reason"),
|
||||
runtimeModeResolvedAt: timestamp("runtime_mode_resolved_at", { withTimezone: true }),
|
||||
runnerProfileJson: jsonb("runner_profile_json").$type<Record<string, unknown>>(),
|
||||
runnerInstanceId: uuid("runner_instance_id"),
|
||||
nativeSessionId: uuid("native_session_id"),
|
||||
nativeIssueId: uuid("native_issue_id"),
|
||||
driverKind: text("driver_kind"),
|
||||
driverVersion: text("driver_version"),
|
||||
completionContractId: uuid("completion_contract_id"),
|
||||
completionContractSha256: text("completion_contract_sha256"),
|
||||
nextEventSeq: bigint("next_event_seq", { mode: "number" }).notNull().default(1),
|
||||
nativePhase: text("native_phase"),
|
||||
nativePhaseUpdatedAt: timestamp("native_phase_updated_at", { withTimezone: true }),
|
||||
sessionIdBefore: text("session_id_before"),
|
||||
sessionIdAfter: text("session_id_after"),
|
||||
logStore: text("log_store"),
|
||||
|
|
@ -60,6 +87,19 @@ export const heartbeatRuns = pgTable(
|
|||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyNativeIssueRunUq: unique("heartbeat_runs_company_native_issue_id_uq").on(
|
||||
table.companyId,
|
||||
table.nativeIssueId,
|
||||
table.id,
|
||||
),
|
||||
companyNativeIssueRunContractUq: unique(
|
||||
"heartbeat_runs_company_native_issue_contract_id_uq",
|
||||
).on(
|
||||
table.companyId,
|
||||
table.nativeIssueId,
|
||||
table.id,
|
||||
table.completionContractId,
|
||||
),
|
||||
companyAgentStartedIdx: index("heartbeat_runs_company_agent_started_idx").on(
|
||||
table.companyId,
|
||||
table.agentId,
|
||||
|
|
|
|||
|
|
@ -111,6 +111,12 @@ export { documentAnnotationComments } from "./document_annotation_comments.js";
|
|||
export { documentAnnotationAnchorSnapshots } from "./document_annotation_anchor_snapshots.js";
|
||||
export { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
export { heartbeatRunEvents } from "./heartbeat_run_events.js";
|
||||
export { completionContracts } from "./completion_contracts.js";
|
||||
export { nativeRunResults } from "./native_run_results.js";
|
||||
export { nativeRunFinalizations } from "./native_run_finalizations.js";
|
||||
export { workAssessments } from "./work_assessments.js";
|
||||
export { statusDecisions } from "./status_decisions.js";
|
||||
export { statusDecisionEffects } from "./status_decision_effects.js";
|
||||
export { heartbeatRunWatchdogDecisions } from "./heartbeat_run_watchdog_decisions.js";
|
||||
export { smokeRuns, smokeRunSteps } from "./smoke_lab.js";
|
||||
export { costEvents } from "./cost_events.js";
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import {
|
|||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
unique,
|
||||
bigint,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { agents } from "./agents.js";
|
||||
import { projects } from "./projects.js";
|
||||
|
|
@ -31,6 +33,8 @@ export const issues = pgTable(
|
|||
title: text("title").notNull(),
|
||||
description: text("description"),
|
||||
status: text("status").notNull().default("backlog"),
|
||||
statusVersion: bigint("status_version", { mode: "number" }).notNull().default(0),
|
||||
lastStatusDecisionId: uuid("last_status_decision_id"),
|
||||
workMode: text("work_mode").notNull().default("standard"),
|
||||
harnessKind: text("harness_kind"),
|
||||
priority: text("priority").notNull().default("medium"),
|
||||
|
|
@ -77,6 +81,7 @@ export const issues = pgTable(
|
|||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIdUq: unique("issues_company_id_uq").on(table.companyId, table.id),
|
||||
companyStatusIdx: index("issues_company_status_idx").on(table.companyId, table.status),
|
||||
companyHarnessKindIdx: index("issues_company_harness_kind_idx").on(table.companyId, table.harnessKind),
|
||||
assigneeStatusIdx: index("issues_company_assignee_status_idx").on(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
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`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import {
|
||||
foreignKey,
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
unique,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
import { completionContracts } from "./completion_contracts.js";
|
||||
|
||||
export const nativeRunResults = pgTable(
|
||||
"native_run_results",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
issueId: uuid("issue_id").notNull(),
|
||||
runId: uuid("run_id").notNull(),
|
||||
turnId: text("turn_id"),
|
||||
completionContractId: uuid("completion_contract_id").notNull(),
|
||||
callerResultId: text("caller_result_id"),
|
||||
callerDedupeKey: text("caller_dedupe_key"),
|
||||
serverFingerprint: text("server_fingerprint").notNull(),
|
||||
schemaStatus: text("schema_status").notNull(),
|
||||
rejectionCode: text("rejection_code"),
|
||||
resultJson: jsonb("result_json").$type<Record<string, unknown>>().notNull(),
|
||||
canonicalSha256: text("canonical_sha256").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIssueRunIdUq: unique("native_run_results_company_issue_run_id_uq").on(
|
||||
table.companyId,
|
||||
table.issueId,
|
||||
table.runId,
|
||||
table.id,
|
||||
),
|
||||
issueCompanyFk: foreignKey({
|
||||
columns: [table.companyId, table.issueId],
|
||||
foreignColumns: [issues.companyId, issues.id],
|
||||
name: "native_run_results_issue_company_fk",
|
||||
}),
|
||||
runContractOwnerFk: foreignKey({
|
||||
columns: [
|
||||
table.companyId,
|
||||
table.issueId,
|
||||
table.runId,
|
||||
table.completionContractId,
|
||||
],
|
||||
foreignColumns: [
|
||||
heartbeatRuns.companyId,
|
||||
heartbeatRuns.nativeIssueId,
|
||||
heartbeatRuns.id,
|
||||
heartbeatRuns.completionContractId,
|
||||
],
|
||||
name: "native_run_results_run_contract_owner_fk",
|
||||
}),
|
||||
completionContractOwnerFk: foreignKey({
|
||||
columns: [table.companyId, table.issueId, table.completionContractId],
|
||||
foreignColumns: [
|
||||
completionContracts.companyId,
|
||||
completionContracts.issueId,
|
||||
completionContracts.id,
|
||||
],
|
||||
name: "native_run_results_completion_contract_owner_fk",
|
||||
}),
|
||||
runFingerprintUq: uniqueIndex("native_run_results_run_fingerprint_uq").on(
|
||||
table.runId,
|
||||
table.serverFingerprint,
|
||||
),
|
||||
runCallerResultUq: uniqueIndex("native_run_results_run_caller_result_uq").on(
|
||||
table.runId,
|
||||
table.callerResultId,
|
||||
),
|
||||
runCallerDedupeUq: uniqueIndex("native_run_results_run_caller_dedupe_uq").on(
|
||||
table.runId,
|
||||
table.callerDedupeKey,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import {
|
||||
foreignKey,
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
integer,
|
||||
jsonb,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { statusDecisions } from "./status_decisions.js";
|
||||
|
||||
export const statusDecisionEffects = pgTable(
|
||||
"status_decision_effects",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
issueId: uuid("issue_id").notNull(),
|
||||
decisionId: uuid("decision_id").notNull(),
|
||||
ordinal: integer("ordinal").notNull(),
|
||||
effectKind: text("effect_kind").notNull(),
|
||||
targetType: text("target_type").notNull(),
|
||||
targetId: text("target_id"),
|
||||
idempotencyKey: text("idempotency_key").notNull(),
|
||||
payload: jsonb("payload").$type<Record<string, unknown>>().notNull(),
|
||||
deliveryState: text("delivery_state").notNull().default("pending"),
|
||||
attemptCount: integer("attempt_count").notNull().default(0),
|
||||
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }),
|
||||
lastError: text("last_error"),
|
||||
deliveredAt: timestamp("delivered_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: "status_decision_effects_issue_company_fk",
|
||||
}),
|
||||
decisionOwnerFk: foreignKey({
|
||||
columns: [table.companyId, table.issueId, table.decisionId],
|
||||
foreignColumns: [statusDecisions.companyId, statusDecisions.issueId, statusDecisions.id],
|
||||
name: "status_decision_effects_decision_owner_fk",
|
||||
}),
|
||||
decisionOrdinalUq: uniqueIndex("status_decision_effects_decision_ordinal_uq").on(
|
||||
table.decisionId,
|
||||
table.ordinal,
|
||||
),
|
||||
idempotencyUq: uniqueIndex("status_decision_effects_company_idempotency_uq").on(
|
||||
table.companyId,
|
||||
table.idempotencyKey,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import {
|
||||
foreignKey,
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
bigint,
|
||||
jsonb,
|
||||
unique,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { workAssessments } from "./work_assessments.js";
|
||||
|
||||
export const statusDecisions = pgTable(
|
||||
"status_decisions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
issueId: uuid("issue_id").notNull(),
|
||||
runId: uuid("run_id").notNull(),
|
||||
assessmentId: uuid("assessment_id").notNull(),
|
||||
decisionVersion: bigint("decision_version", { mode: "number" }).notNull(),
|
||||
policyVersion: text("policy_version").notNull(),
|
||||
fromStatus: text("from_status").notNull(),
|
||||
toStatus: text("to_status").notNull(),
|
||||
reasonCode: text("reason_code").notNull(),
|
||||
decisionJson: jsonb("decision_json").$type<Record<string, unknown>>().notNull(),
|
||||
decisionDigest: text("decision_digest").notNull(),
|
||||
applicationState: text("application_state").notNull().default("proposed"),
|
||||
supersedesDecisionId: uuid("supersedes_decision_id"),
|
||||
appliedAt: timestamp("applied_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIssueIdUq: unique("status_decisions_company_issue_id_uq").on(
|
||||
table.companyId,
|
||||
table.issueId,
|
||||
table.id,
|
||||
),
|
||||
companyIssueRunAssessmentIdUq: unique(
|
||||
"status_decisions_company_issue_run_assessment_id_uq",
|
||||
).on(
|
||||
table.companyId,
|
||||
table.issueId,
|
||||
table.runId,
|
||||
table.assessmentId,
|
||||
table.id,
|
||||
),
|
||||
issueCompanyFk: foreignKey({
|
||||
columns: [table.companyId, table.issueId],
|
||||
foreignColumns: [issues.companyId, issues.id],
|
||||
name: "status_decisions_issue_company_fk",
|
||||
}),
|
||||
assessmentOwnerFk: foreignKey({
|
||||
columns: [table.companyId, table.issueId, table.runId, table.assessmentId],
|
||||
foreignColumns: [
|
||||
workAssessments.companyId,
|
||||
workAssessments.issueId,
|
||||
workAssessments.runId,
|
||||
workAssessments.id,
|
||||
],
|
||||
name: "status_decisions_assessment_owner_fk",
|
||||
}),
|
||||
supersedesOwnerFk: foreignKey({
|
||||
columns: [table.companyId, table.issueId, table.supersedesDecisionId],
|
||||
foreignColumns: [table.companyId, table.issueId, table.id],
|
||||
name: "status_decisions_supersedes_owner_fk",
|
||||
}),
|
||||
issueVersionUq: uniqueIndex("status_decisions_company_issue_version_uq").on(
|
||||
table.companyId,
|
||||
table.issueId,
|
||||
table.decisionVersion,
|
||||
),
|
||||
assessmentUq: uniqueIndex("status_decisions_company_assessment_uq").on(
|
||||
table.companyId,
|
||||
table.assessmentId,
|
||||
),
|
||||
issueDigestUq: uniqueIndex("status_decisions_company_issue_digest_uq").on(
|
||||
table.companyId,
|
||||
table.issueId,
|
||||
table.decisionDigest,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
check,
|
||||
foreignKey,
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
bigint,
|
||||
jsonb,
|
||||
unique,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
import { completionContracts } from "./completion_contracts.js";
|
||||
import { nativeRunResults } from "./native_run_results.js";
|
||||
|
||||
export const workAssessments = pgTable(
|
||||
"work_assessments",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
issueId: uuid("issue_id").notNull(),
|
||||
runId: uuid("run_id").notNull(),
|
||||
turnId: text("turn_id"),
|
||||
contractId: uuid("contract_id").notNull(),
|
||||
resultId: uuid("result_id").notNull(),
|
||||
triggerKind: text("trigger_kind").notNull(),
|
||||
triggerRef: text("trigger_ref"),
|
||||
triggerCapability: text("trigger_capability"),
|
||||
triggerActorCompanyId: uuid("trigger_actor_company_id").notNull().references(() => companies.id),
|
||||
priorIssueStatus: text("prior_issue_status").notNull(),
|
||||
priorStatusVersion: bigint("prior_status_version", { mode: "number" }).notNull(),
|
||||
priorDecisionId: uuid("prior_decision_id"),
|
||||
policyVersion: text("policy_version").notNull(),
|
||||
assessmentJson: jsonb("assessment_json").$type<Record<string, unknown>>().notNull(),
|
||||
inputDigest: text("input_digest").notNull(),
|
||||
supersedesAssessmentId: uuid("supersedes_assessment_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIssueRunIdUq: unique("work_assessments_company_issue_run_id_uq").on(
|
||||
table.companyId,
|
||||
table.issueId,
|
||||
table.runId,
|
||||
table.id,
|
||||
),
|
||||
issueCompanyFk: foreignKey({
|
||||
columns: [table.companyId, table.issueId],
|
||||
foreignColumns: [issues.companyId, issues.id],
|
||||
name: "work_assessments_issue_company_fk",
|
||||
}),
|
||||
runOwnerFk: foreignKey({
|
||||
columns: [table.companyId, table.issueId, table.runId],
|
||||
foreignColumns: [heartbeatRuns.companyId, heartbeatRuns.nativeIssueId, heartbeatRuns.id],
|
||||
name: "work_assessments_run_owner_fk",
|
||||
}),
|
||||
contractOwnerFk: foreignKey({
|
||||
columns: [table.companyId, table.issueId, table.contractId],
|
||||
foreignColumns: [
|
||||
completionContracts.companyId,
|
||||
completionContracts.issueId,
|
||||
completionContracts.id,
|
||||
],
|
||||
name: "work_assessments_contract_owner_fk",
|
||||
}),
|
||||
resultOwnerFk: foreignKey({
|
||||
columns: [table.companyId, table.issueId, table.runId, table.resultId],
|
||||
foreignColumns: [
|
||||
nativeRunResults.companyId,
|
||||
nativeRunResults.issueId,
|
||||
nativeRunResults.runId,
|
||||
nativeRunResults.id,
|
||||
],
|
||||
name: "work_assessments_result_owner_fk",
|
||||
}),
|
||||
supersedesOwnerFk: foreignKey({
|
||||
columns: [table.companyId, table.issueId, table.runId, table.supersedesAssessmentId],
|
||||
foreignColumns: [table.companyId, table.issueId, table.runId, table.id],
|
||||
name: "work_assessments_supersedes_owner_fk",
|
||||
}),
|
||||
triggerActorCompanyCheck: check(
|
||||
"work_assessments_trigger_actor_company_check",
|
||||
sql`${table.triggerActorCompanyId} = ${table.companyId}`,
|
||||
),
|
||||
issueInputUq: uniqueIndex("work_assessments_company_issue_input_uq").on(
|
||||
table.companyId,
|
||||
table.issueId,
|
||||
table.inputDigest,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,5 +1,19 @@
|
|||
export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js";
|
||||
export { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./adapter-auth-check-code.js";
|
||||
export {
|
||||
nativeFinalizationResultSchema,
|
||||
nativeFinalizationResultV1Schema,
|
||||
nativeReportedWorkDispositionSchema,
|
||||
type NativeFinalizationResultInput,
|
||||
} from "./validators/native-finalization.js";
|
||||
export {
|
||||
NATIVE_FINALIZATION_SCHEMA,
|
||||
type NativeFinalizationResult,
|
||||
type NativeFinalizationResultV1,
|
||||
type NativeReportedWorkDisposition,
|
||||
type NativeRuntimeMode,
|
||||
type NativeRunTerminalState,
|
||||
} from "./types/native-finalization.js";
|
||||
export {
|
||||
decisionEffectStalenessSchema,
|
||||
decisionOptionStyleSchema,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
export { decisionEffectTargetIssueIds } from "./decision.js";
|
||||
export {
|
||||
NATIVE_FINALIZATION_SCHEMA,
|
||||
type NativeFinalizationResult,
|
||||
type NativeFinalizationResultV1,
|
||||
type NativeReportedWorkDisposition,
|
||||
type NativeRuntimeMode,
|
||||
type NativeRunTerminalState,
|
||||
} from "./native-finalization.js";
|
||||
export type {
|
||||
Company,
|
||||
InteractionResolverGovernance,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
export type NativeRuntimeMode = "legacy" | "native";
|
||||
export const NATIVE_FINALIZATION_SCHEMA = "paperclip.native-finalization.v1" as const;
|
||||
export type NativeReportedWorkDisposition = "done" | "blocked" | "needs_review" | "yielded";
|
||||
export type NativeRunTerminalState = "succeeded" | "failed" | "cancelled";
|
||||
|
||||
export interface NativeFinalizationResultV1 {
|
||||
schema: typeof NATIVE_FINALIZATION_SCHEMA;
|
||||
runtimeMode: "native";
|
||||
runId: string;
|
||||
issueId: string;
|
||||
companyId: string;
|
||||
result: Record<string, unknown>;
|
||||
terminal: {
|
||||
schema: "paperclip.prp.terminal.v1";
|
||||
turnTerminalState: "completed" | "failed" | "interrupted" | "cancelled";
|
||||
runTerminalState: NativeRunTerminalState;
|
||||
reportedWorkDisposition: NativeReportedWorkDisposition;
|
||||
};
|
||||
turnId: string | null;
|
||||
sourceInstanceId: string;
|
||||
normalizedSessionId: string;
|
||||
providerSessionId: string | null;
|
||||
driverKind: string;
|
||||
driverVersion: string;
|
||||
nativeEventCount: number;
|
||||
highestContiguousSourceSeq: number;
|
||||
workspaceFinalizeStatus: "pending" | "succeeded" | "failed";
|
||||
}
|
||||
|
||||
export type NativeFinalizationResult = NativeFinalizationResultV1;
|
||||
|
|
@ -1,3 +1,10 @@
|
|||
export {
|
||||
nativeFinalizationResultSchema,
|
||||
nativeFinalizationResultV1Schema,
|
||||
nativeReportedWorkDispositionSchema,
|
||||
type NativeFinalizationResultInput,
|
||||
} from "./native-finalization.js";
|
||||
|
||||
export {
|
||||
decisionEffectStalenessSchema,
|
||||
decisionOptionStyleSchema,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { NATIVE_FINALIZATION_SCHEMA } from "../types/native-finalization.js";
|
||||
import { nativeFinalizationResultSchema } from "./native-finalization.js";
|
||||
|
||||
const validResult = {
|
||||
schema: NATIVE_FINALIZATION_SCHEMA,
|
||||
runtimeMode: "native",
|
||||
runId: "10000000-0000-4000-8000-000000000001",
|
||||
issueId: "10000000-0000-4000-8000-000000000002",
|
||||
companyId: "10000000-0000-4000-8000-000000000003",
|
||||
result: { summary: "Completed the requested work" },
|
||||
terminal: {
|
||||
schema: "paperclip.prp.terminal.v1",
|
||||
turnTerminalState: "completed",
|
||||
runTerminalState: "succeeded",
|
||||
reportedWorkDisposition: "done",
|
||||
},
|
||||
turnId: "turn-1",
|
||||
sourceInstanceId: "runner-instance-1",
|
||||
normalizedSessionId: "session-1",
|
||||
providerSessionId: "provider-session-1",
|
||||
driverKind: "codex",
|
||||
driverVersion: "1.0.0",
|
||||
nativeEventCount: 12,
|
||||
highestContiguousSourceSeq: 12,
|
||||
workspaceFinalizeStatus: "succeeded",
|
||||
} as const;
|
||||
|
||||
describe("native finalization validators", () => {
|
||||
it("accepts the complete v1 finalization contract", () => {
|
||||
expect(nativeFinalizationResultSchema.parse(validResult)).toEqual(validResult);
|
||||
});
|
||||
|
||||
it("fails closed for unknown required versions and extra fields", () => {
|
||||
expect(nativeFinalizationResultSchema.safeParse({
|
||||
...validResult,
|
||||
schema: "paperclip.native-finalization.v2",
|
||||
}).success).toBe(false);
|
||||
expect(nativeFinalizationResultSchema.safeParse({
|
||||
...validResult,
|
||||
unrecognizedRequiredField: true,
|
||||
}).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invalid identities and sequence counters", () => {
|
||||
expect(nativeFinalizationResultSchema.safeParse({
|
||||
...validResult,
|
||||
runId: "not-a-uuid",
|
||||
}).success).toBe(false);
|
||||
expect(nativeFinalizationResultSchema.safeParse({
|
||||
...validResult,
|
||||
highestContiguousSourceSeq: -1,
|
||||
}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { z } from "zod";
|
||||
import { NATIVE_FINALIZATION_SCHEMA } from "../types/native-finalization.js";
|
||||
|
||||
export const nativeFinalizationResultV1Schema = z.object({
|
||||
schema: z.literal(NATIVE_FINALIZATION_SCHEMA),
|
||||
runtimeMode: z.literal("native"),
|
||||
runId: z.string().uuid(),
|
||||
issueId: z.string().uuid(),
|
||||
companyId: z.string().uuid(),
|
||||
result: z.record(z.string(), z.unknown()),
|
||||
terminal: z.object({
|
||||
schema: z.literal("paperclip.prp.terminal.v1"),
|
||||
turnTerminalState: z.enum(["completed", "failed", "interrupted", "cancelled"]),
|
||||
runTerminalState: z.enum(["succeeded", "failed", "cancelled"]),
|
||||
reportedWorkDisposition: z.enum(["done", "blocked", "needs_review", "yielded"]),
|
||||
}).strict(),
|
||||
turnId: z.string().min(1).nullable(),
|
||||
sourceInstanceId: z.string().min(1).max(160),
|
||||
normalizedSessionId: z.string().min(1),
|
||||
providerSessionId: z.string().min(1).nullable(),
|
||||
driverKind: z.string().min(1),
|
||||
driverVersion: z.string().min(1),
|
||||
nativeEventCount: z.number().int().nonnegative(),
|
||||
highestContiguousSourceSeq: z.number().int().nonnegative(),
|
||||
workspaceFinalizeStatus: z.enum(["pending", "succeeded", "failed"]),
|
||||
}).strict();
|
||||
|
||||
export const nativeReportedWorkDispositionSchema = z.enum([
|
||||
"done",
|
||||
"blocked",
|
||||
"needs_review",
|
||||
"yielded",
|
||||
]);
|
||||
export const nativeFinalizationResultSchema = nativeFinalizationResultV1Schema;
|
||||
export type NativeFinalizationResultInput = z.infer<typeof nativeFinalizationResultSchema>;
|
||||
|
|
@ -3203,6 +3203,8 @@ const issueListSelect = {
|
|||
END
|
||||
`,
|
||||
status: issues.status,
|
||||
statusVersion: issues.statusVersion,
|
||||
lastStatusDecisionId: issues.lastStatusDecisionId,
|
||||
workMode: issues.workMode,
|
||||
harnessKind: issues.harnessKind,
|
||||
priority: issues.priority,
|
||||
|
|
|
|||
Loading…
Reference in New Issue