feat(db): add indexed runner authority and history contracts

Part 1 of the opt-in indexed history draft series. Validated at the integrated tip; activation remains disabled by default.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-29 16:20:49 -05:00
1 parent e6bed1231a
commit 8ecb181d9c
44 files changed
+56887 -48246

No files matched your search

+119 -1
View File
@@ -272,7 +272,12 @@ path.
Durable agent session goals are an additive projection on
`agent_task_sessions`, distinct from the business-goal hierarchy. The row stores
the negotiated goal capability, normalized snapshot and status, desired state,
provider source cursor, monotonic projection revision, and observation time.
provider source cursor, opaque equality-only projection revision token, and
observation time. `goal_revision` remains as a legacy numeric compatibility
value; the first mutation writes `goal_revision_token` without incrementing the
legacy column. A stale numeric expectation cannot match after token activation.
The source epoch/cursor orders provider updates and is independent of this CAS
token.
`agent_session_goal_actions` is the control outbox: `(session_id, request_id)`
is unique, so retries return the original accepted action. Provider source
ordering fences duplicate and stale updates, and a cleared projection retains
@@ -353,6 +358,14 @@ plugin-owned database schemas. See `doc/DEVELOPING.md` for the current
`paperclipai db:backup` / `pnpm db:backup` commands and backup retention
configuration.
The JavaScript exporter reads catalog and table data through one repeatable-read,
read-only transaction with bounded row cursors. Coordinated background backup
code can use `withDatabaseBackupSnapshot` to read authority rows and supply its
exported `snapshotId` to `runDatabaseBackup`; both export engines use that exact
database snapshot. The owning callback must stay open until export completes.
An expired snapshot fails the backup. This database boundary alone does not
capture runner SQLite stores, provider files, or external output objects.
Database backups do not include non-database instance files such as local-disk
uploads, workspace files, or the local encrypted secrets master key. Back those paths
up separately when you need full instance disaster recovery.
@@ -432,3 +445,108 @@ cleanup authority; it does not prove that remote inference has stopped. Recovery
revokes the previous boot identity with a conditional update. Its own claim also
expires so another sweep can finish cleanup after a restart. Historical rows keep
null ownership fields and follow the previous recovery path.
### Experimental indexed native authority
Fresh local Codex sessions can opt into `PAPERCLIP_NATIVE_INDEXED_STATE=1`.
Migration `0285_flimsy_eternals` adds company/run/session-scoped
`native_session_authorities` (bounded current state and generation),
`native_authority_records` (immutable protocol receipts and raw events), and
`native_source_cursors` (cached contiguous run-log acceptance cursors). The
production adapter lives in `server/src/services/native-runtime/postgres-authority-store.ts`.
The controller checkpoint and raw protocol event receipt share a transaction
before a transport ACK. Driver-normalized `heartbeat_run_events` have a different
sequence and continue through the existing event writer. They must not share
raw transport sequence keys.
Migration `0293_dazzling_the_hood` reserves sequence zero for unordered effect
receipts. Effects are addressed by company/session/semantic identity, not by
sequence. Their existing positive sequences remain immutable. Command/event
sequences retain their positive, per-run uniqueness constraints.
Migration `0292_rainy_leader` changes the authority's `generation` from a
signed integer to an opaque `r:<UUIDv4>` commit identity, with its exact
`committed_from` predecessor in the same transaction. Writers compare equality;
they do not increment or sort revisions. Existing decimal revisions remain
readable, including the old signed-64-bit maximum. The next successful write
replaces that revision without changing the run or session. Interrupted legacy
activation proves one transition from the stored predecessor, not arithmetic
on an opaque value. Event sequences remain a separate, numeric contract.
Migration `0287_nebulous_lockjaw` adds paged unresolved process owners independent
of transcript retention. Migration `0288_brown_alex_power` adds a small output
body catalog keyed by company, run, body digest and source sequence. Migration
`0289_fresh_firebird` adds current server-observed process lifecycle evidence and
an event foreign-key index for the body catalog. Process evidence is committed
with each server launch/identity/stop event; old runs backfill once under the run
lock. Neither ordinary recovery nor a body download scans the run's historical
output. The lifecycle row survives historical event retention and is invalidated
by a later launch. It is not a substitute for exact process-tree ownership.
Migration `0290_broken_warbound` distinguishes inline receipt JSON from immutable
payload references and stores the original byte length for page admission.
Large receipts and output chunks use the configured local/S3 storage provider.
Upload and exact read-back verification precede the database transaction; the
original canonical digest remains the replay identity. Missing or corrupt
required objects fail closed. A failed publication can leave an immutable
orphan; automatic orphan collection is not implemented yet.
Current authority format `paperclip.runner.current-authority.v2` keeps pending
work and a fixed set of typed receipt references. Recovery reads just those
receipts, validates their scope/sequence/digest, and rechecks the current
generation. Existing completion-contract and accepted-result checks then use
the resolved bounded view. Older indexed rows remain readable; older binaries
reject the v2 current-state schema instead of mistaking missing settled arrays
for empty history.
Runner and Codex provider state use private local SQLite WAL/FULL databases;
small JSON files are locators, never standalone execution authority. Do not
copy a live SQLite file without a consistent backup protocol or its committed
WAL. Legacy retained-session import and cross-store operational backup/restore
remain unqualified, so this format is disabled for fresh sessions by default.
See the [design and qualification status](plans/2026-09-28-unbounded-runner-history.md).
Run-output progress uses a `bigint` `heartbeat_runs.last_output_seq`, matching
the larger run-event counters. Migration 0291 removes the former signed 32-bit
output-count boundary. API values remain JavaScript-safe numeric progress;
transparent lifetime epoch rotation is specified separately in the
[history lifetime contract](architecture/runner-history-lifetime.md).
Command receipts have an optional `sequence_epoch` namespace (migration 0294).
The retained namespace is the empty string. Uniqueness includes that namespace
for ordered records, while command identity remains unique across the entire
run. Controller transitions store exact close receipts and the new current head
in one transaction; resetting an ordinal alone never establishes a new epoch.
The runner history event-epoch migration `0295_cooing_zodiak.sql` extends the
existing `native_authority_records.sequence_epoch` constraint to raw events.
Event pages select the exact namespace; identity deduplication remains across
the run. This does not change normalized run-log source or public event cursors.
Normalized source delivery uses migrations 0296–0297. One current source head
and immutable close receipts separate local event ordinals from lifetime
ordering. The first successor event closes the exact contiguous predecessor in
the same transaction. Output chunk keys are body-local offsets, independent of
source ordinals. The migration validates old chunk identities before coalescing
exact duplicates. Migration 0296 replaces the existing replay uniqueness index
under the transactional migration lock; large installations need a maintenance
window for that one-time rebuild. This is not a foreground continuation step.
Goal projections store the source epoch separately from source run ownership.
Migration `0298_brown_northstar` adds nullable
`agent_task_sessions.goal_revision_token`. Existing rows retain their numeric
`goal_revision` until a successful mutation installs an opaque `r:<UUIDv4>`
token. This is an equality authority, not a sortable revision or source cursor.
Migration `0299_flaky_justin_hammer` converts public
`heartbeat_run_events.id` values to text (preserving old IDs as decimal strings),
adds `event_epoch`, and creates indexed epoch heads and semantic-lane links.
New event cursors are `e:<epoch>:<seq>`; sequence values are bounded to one
epoch and must not be compared across epochs. The migration backfills the link
and head indexes from existing history in one operation. That backfill and its
index/constraint replacement are O(history) work and may hold transactional
locks: plan a maintenance window for large installations. It is a one-time
migration cost, not hidden foreground continuation work.
+66 -1
View File
@@ -4,7 +4,7 @@ import path from "node:path";
import { gunzipSync } from "node:zlib";
import { afterEach, describe, expect, it } from "vitest";
import postgres from "postgres";
import { createBufferedTextFileWriter, runDatabaseBackup, runDatabaseRestore } from "./backup-lib.js";
import { createBufferedTextFileWriter, runDatabaseBackup, runDatabaseRestore, withDatabaseBackupSnapshot } from "./backup-lib.js";
import { ensurePostgresDatabase } from "./client.js";
import {
getEmbeddedPostgresTestSupport,
@@ -603,3 +603,68 @@ describeEmbeddedPostgres("runDatabaseBackup", () => {
20_000,
);
});
describeEmbeddedPostgres("coordinated database snapshots", () => {
for (const engine of ["javascript", "transformed-auto", "pg_dump"] as const) {
it.skipIf(engine === "pg_dump" && !process.env.PAPERCLIP_TEST_PG_DUMP)(
`restores one exported authority/receipt point while writers continue (${engine})`, async () => {
const connectionString = await createTempDatabase();
const restoreConnectionString = await createSiblingDatabase(connectionString, "snapshot_restore_target");
const writer = postgres(connectionString, { max: 1, onnotice: () => {} });
const restored = postgres(restoreConnectionString, { max: 1, onnotice: () => {} });
const backupDir = createTempDir("paperclip-db-snapshot-");
try {
await writer.unsafe(`
CREATE TABLE snapshot_receipts (id text PRIMARY KEY, body text NOT NULL);
CREATE TABLE snapshot_authority (id integer PRIMARY KEY, generation text NOT NULL, receipt text NOT NULL REFERENCES snapshot_receipts(id));
INSERT INTO snapshot_receipts VALUES ('before', repeat('original receipt ', 4096));
INSERT INTO snapshot_authority VALUES (1, '9007199254740993', 'before');
`);
const backup = await withDatabaseBackupSnapshot({ connectionString }, async ({ id, sql }) => {
expect((await sql`SELECT generation FROM snapshot_authority`)[0]?.generation).toBe("9007199254740993");
await writer.begin(async tx => {
await tx`INSERT INTO snapshot_receipts VALUES ('after', 'new receipt')`;
await tx`UPDATE snapshot_authority SET generation = '9007199254740994', receipt = 'after'`;
await tx`DELETE FROM snapshot_receipts WHERE id = 'before'`;
});
const result = await runDatabaseBackup({ connectionString, backupDir,
retention: { dailyDays: 1, weeklyWeeks: 0, monthlyMonths: 0 }, snapshotId: id,
backupEngine: engine === "transformed-auto" ? "auto" : engine,
...(engine === "transformed-auto" ? { excludeTables: ["not_a_table"] } : {}),
});
expect((await sql`SELECT generation FROM snapshot_authority`)[0]?.generation).toBe("9007199254740993");
return result;
});
await runDatabaseRestore({ connectionString: restoreConnectionString, backupFile: backup.backupFile });
expect(await restored`SELECT generation, receipt FROM snapshot_authority`).toEqual([{ generation: "9007199254740993", receipt: "before" }]);
expect(await restored`SELECT id, body FROM snapshot_receipts`).toEqual([{ id: "before", body: "original receipt ".repeat(4096) }]);
expect((await writer`SELECT generation FROM snapshot_authority`)[0]?.generation).toBe("9007199254740994");
} finally { await Promise.all([writer.end(), restored.end()]); }
}, 90_000,
);
}
it("does not publish a backup if the exported snapshot has expired", async () => {
const connectionString = await createTempDatabase();
const backupDir = createTempDir("paperclip-db-expired-snapshot-");
const snapshotId = await withDatabaseBackupSnapshot({ connectionString }, async ({ id }) => id);
const engines = process.env.PAPERCLIP_TEST_PG_DUMP ? ["javascript", "pg_dump", "auto"] as const : ["javascript"] as const;
for (const backupEngine of engines) {
const directory = path.join(backupDir, backupEngine);
await expect(runDatabaseBackup({ connectionString, backupDir: directory, snapshotId, backupEngine,
retention: { dailyDays: 1, weeklyWeeks: 0, monthlyMonths: 0 },
})).rejects.toThrow(/snapshot/i);
expect(fs.readdirSync(directory)).toEqual([]);
}
}, 90_000);
});
it("validates snapshot identifiers before opening a database or output file", async () => {
const backupDir = path.join(createTempDir("paperclip-db-invalid-snapshot-"), "absent");
for (const snapshotId of ["", "snapshot' ; SELECT 1; --", "00000001-00000001-1\n", "00000001-00000001-" + "9".repeat(100)]) {
await expect(runDatabaseBackup({ connectionString: "postgres://invalid:1/unreachable", backupDir, snapshotId,
retention: { dailyDays: 1, weeklyWeeks: 0, monthlyMonths: 0 },
})).rejects.toThrow("database_backup_snapshot_invalid");
expect(fs.existsSync(backupDir)).toBe(false);
}
});
+428 -411
View File
@@ -28,6 +28,9 @@ export type RunDatabaseBackupOptions = {
excludeTables?: string[];
nullifyColumns?: Record<string, string[]>;
backupEngine?: "auto" | "pg_dump" | "javascript";
/** Exported by a still-open, same-database transaction. Both engines must
* import this exact snapshot; failure must never select a newer point. */
snapshotId?: string;
};
export type RunDatabaseBackupResult = {
@@ -321,6 +324,7 @@ async function runPgDumpBackup(opts: {
connectionString: string;
backupFile: string;
connectTimeout: number;
snapshotId?: string;
}): Promise<void> {
const pgDumpBin = process.env.PAPERCLIP_PG_DUMP_PATH || "pg_dump";
const child = spawn(
@@ -332,6 +336,7 @@ async function runPgDumpBackup(opts: {
"--if-exists",
"--no-owner",
"--no-privileges",
...(opts.snapshotId ? [`--snapshot=${opts.snapshotId}`] : []),
],
{
stdio: ["ignore", "pipe", "pipe"],
@@ -524,12 +529,37 @@ export function createBufferedTextFileWriter(filePath: string, maxBufferedBytes
};
}
function validateSnapshotId(id: string): void {
// PostgreSQL exports three bounded hexadecimal/decimal components. Validate
// before interpolating its required SQL string literal or starting a child.
if (id !== id.trim() || !/^[0-9a-f]{8}-[0-9a-f]{8}-[1-9][0-9]{0,9}$/i.test(id)) throw new Error("database_backup_snapshot_invalid");
}
/** Own a repeatable database read point while coordinating a background backup.
* The callback may read bounded authority rows through sql and pass id to the
* exporter. No execution transaction should wait for this history-sized job.
* Exported snapshots cease to be importable when the callback finishes. */
export async function withDatabaseBackupSnapshot<T>(
options: Pick<RunDatabaseBackupOptions, "connectionString" | "connectTimeoutSeconds">,
consume: (snapshot: { id: string; sql: postgres.TransactionSql }) => Promise<T>,
): Promise<T> {
const pool = postgres(options.connectionString, { max: 1, connect_timeout: Math.max(1, Math.trunc(options.connectTimeoutSeconds ?? 5)) });
try {
return await pool.begin("isolation level repeatable read read only", async sql => {
const [row] = await sql<{ id: string }[]>`SELECT pg_export_snapshot() AS id`;
if (!row) throw new Error("database_backup_snapshot_missing");
validateSnapshotId(row.id);
return consume({ id: row.id, sql });
}) as T;
} finally { await pool.end(); }
}
export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise<RunDatabaseBackupResult> {
if (opts.snapshotId !== undefined) validateSnapshotId(opts.snapshotId);
const filenamePrefix = opts.filenamePrefix ?? "paperclip";
const retention = opts.retention;
const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5));
const backupEngine = opts.backupEngine ?? "auto";
let effectiveBackupEngine = backupEngine;
const canUsePgDump = !hasBackupTransforms(opts);
const excludedTableNames = normalizeTableNameSet(opts.excludeTables);
const nullifiedColumnsByTable = normalizeNullifyColumnMap(opts.nullifyColumns);
@@ -554,6 +584,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
connectionString: opts.connectionString,
backupFile,
connectTimeout,
snapshotId: opts.snapshotId,
});
await writer.abort();
const sizeBytes = statSync(backupFile).size;
@@ -570,7 +601,6 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
if (backupEngine === "pg_dump") {
throw error;
}
effectiveBackupEngine = "javascript";
sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
sqlClosed = false;
}
@@ -578,447 +608,434 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
await sql`SELECT 1`;
const emit = (line: string) => writer.emit(line);
const emitStatement = (statement: string) => {
emit(statement);
emit(STATEMENT_BREAKPOINT);
};
const emitStatementBoundary = () => {
emit(STATEMENT_BREAKPOINT);
};
await sql.begin("isolation level repeatable read read only", async sql => {
if (opts.snapshotId) await sql.unsafe(`SET TRANSACTION SNAPSHOT '${opts.snapshotId}'`);
const emit = (line: string) => writer.emit(line);
const emitStatement = (statement: string) => {
emit(statement);
emit(STATEMENT_BREAKPOINT);
};
const emitStatementBoundary = () => {
emit(STATEMENT_BREAKPOINT);
};
emit("-- Paperclip database backup");
emit(`-- Created: ${new Date().toISOString()}`);
emit("");
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[]>`
SELECT table_schema AS schema_name, table_name AS tablename
FROM information_schema.tables
WHERE table_type = 'BASE TABLE'
AND ${sql.unsafe(nonSystemSchemaPredicate("table_schema"))}
ORDER BY table_schema, table_name
`;
const tables = allTables;
const includedTableNames = new Set(tables.map(({ schema_name, tablename }) => tableKey(schema_name, tablename)));
const includedSchemas = new Set(tables.map(({ schema_name }) => schema_name));
// Get all enums
const enums = await sql<{ schema_name: string; typname: string; labels: string[] }[]>`
SELECT n.nspname AS schema_name, t.typname, array_agg(e.enumlabel ORDER BY e.enumsortorder) AS labels
FROM pg_type t
JOIN pg_enum e ON t.oid = e.enumtypid
JOIN pg_namespace n ON t.typnamespace = n.oid
WHERE ${sql.unsafe(nonSystemSchemaPredicate("n.nspname"))}
GROUP BY n.nspname, t.typname
ORDER BY n.nspname, t.typname
`;
for (const e of enums) includedSchemas.add(e.schema_name);
const allSequences = await sql<SequenceDefinition[]>`
SELECT
s.sequence_schema,
s.sequence_name,
s.data_type,
s.start_value,
s.minimum_value,
s.maximum_value,
s.increment,
s.cycle_option,
tblns.nspname AS owner_schema,
tbl.relname AS owner_table,
attr.attname AS owner_column
FROM information_schema.sequences s
JOIN pg_class seq ON seq.relname = s.sequence_name
JOIN pg_namespace n ON n.oid = seq.relnamespace AND n.nspname = s.sequence_schema
LEFT JOIN pg_depend dep ON dep.objid = seq.oid AND dep.deptype = 'a'
LEFT JOIN pg_class tbl ON tbl.oid = dep.refobjid
LEFT JOIN pg_namespace tblns ON tblns.oid = tbl.relnamespace
LEFT JOIN pg_attribute attr ON attr.attrelid = tbl.oid AND attr.attnum = dep.refobjsubid
WHERE ${sql.unsafe(nonSystemSchemaPredicate("s.sequence_schema"))}
ORDER BY s.sequence_schema, s.sequence_name
`;
const sequences = allSequences.filter(
(seq) => !seq.owner_table || includedTableNames.has(tableKey(seq.owner_schema ?? "public", seq.owner_table)),
);
const schemas = new Set<string>(includedSchemas);
for (const seq of sequences) schemas.add(seq.sequence_schema);
const extraSchemas = [...schemas].filter((schemaName) => schemaName !== "public");
if (extraSchemas.length > 0) {
emit("-- Schemas");
for (const schemaName of extraSchemas) {
emitStatement(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(schemaName)};`);
}
emit("-- Paperclip database backup");
emit(`-- Created: ${new Date().toISOString()}`);
emit("");
}
for (const e of enums) {
const labels = e.labels.map((l) => `'${l.replace(/'/g, "''")}'`).join(", ");
emitStatement(`CREATE TYPE ${quoteQualifiedName(e.schema_name, e.typname)} AS ENUM (${labels});`);
}
if (enums.length > 0) emit("");
const extensions = await sql<ExtensionDefinition[]>`
SELECT
e.extname AS extension_name,
n.nspname AS schema_name
FROM pg_extension e
JOIN pg_namespace n ON n.oid = e.extnamespace
WHERE e.extname <> 'plpgsql'
ORDER BY e.extname
`;
if (extensions.length > 0) {
emit("-- Extensions");
for (const extension of extensions) {
emitStatement(
`CREATE EXTENSION IF NOT EXISTS ${quoteIdentifier(extension.extension_name)} WITH SCHEMA ${quoteIdentifier(extension.schema_name)};`,
);
}
emitStatement("BEGIN;");
emitStatement("SET LOCAL session_replication_role = replica;");
emitStatement("SET LOCAL client_min_messages = warning;");
emitStatement("SET LOCAL check_function_bodies = false;");
emit("");
}
if (sequences.length > 0) {
emit("-- Sequences");
for (const seq of sequences) {
const qualifiedSequenceName = quoteQualifiedName(seq.sequence_schema, seq.sequence_name);
emitStatement(`DROP SEQUENCE IF EXISTS ${qualifiedSequenceName} CASCADE;`);
emitStatement(
`CREATE SEQUENCE ${qualifiedSequenceName} AS ${seq.data_type} INCREMENT BY ${seq.increment} MINVALUE ${seq.minimum_value} MAXVALUE ${seq.maximum_value} START WITH ${seq.start_value}${seq.cycle_option === "YES" ? " CYCLE" : " NO CYCLE"};`,
);
}
emit("");
}
// Get full CREATE TABLE DDL via column info
for (const { schema_name, tablename } of tables) {
const qualifiedTableName = quoteQualifiedName(schema_name, tablename);
const columns = await sql<{
column_name: string;
data_type: string;
udt_schema: string;
udt_name: string;
is_nullable: string;
column_default: string | null;
character_maximum_length: number | null;
numeric_precision: number | null;
numeric_scale: number | null;
}[]>`
SELECT column_name, data_type, udt_schema, udt_name, is_nullable, column_default,
character_maximum_length, numeric_precision, numeric_scale
FROM information_schema.columns
WHERE table_schema = ${schema_name} AND table_name = ${tablename}
ORDER BY ordinal_position
const allTables = await sql<TableDefinition[]>`
SELECT table_schema AS schema_name, table_name AS tablename
FROM information_schema.tables
WHERE table_type = 'BASE TABLE'
AND ${sql.unsafe(nonSystemSchemaPredicate("table_schema"))}
ORDER BY table_schema, table_name
`;
const tables = allTables;
const includedTableNames = new Set(tables.map(({ schema_name, tablename }) => tableKey(schema_name, tablename)));
const includedSchemas = new Set(tables.map(({ schema_name }) => schema_name));
emit(`-- Table: ${schema_name}.${tablename}`);
emitStatement(`DROP TABLE IF EXISTS ${qualifiedTableName} CASCADE;`);
// Get all enums
const enums = await sql<{ schema_name: string; typname: string; labels: string[] }[]>`
SELECT n.nspname AS schema_name, t.typname, array_agg(e.enumlabel ORDER BY e.enumsortorder) AS labels
FROM pg_type t
JOIN pg_enum e ON t.oid = e.enumtypid
JOIN pg_namespace n ON t.typnamespace = n.oid
WHERE ${sql.unsafe(nonSystemSchemaPredicate("n.nspname"))}
GROUP BY n.nspname, t.typname
ORDER BY n.nspname, t.typname
`;
for (const e of enums) includedSchemas.add(e.schema_name);
const colDefs: string[] = [];
for (const col of columns) {
let typeStr: string;
if (col.data_type === "USER-DEFINED") {
typeStr = quoteQualifiedName(col.udt_schema, col.udt_name);
} else if (col.data_type === "ARRAY") {
const elementType = col.udt_name.replace(/^_/, "");
typeStr = col.udt_schema === "pg_catalog"
? `${elementType}[]`
: `${quoteQualifiedName(col.udt_schema, elementType)}[]`;
} else if (col.data_type === "character varying") {
typeStr = col.character_maximum_length
? `varchar(${col.character_maximum_length})`
: "varchar";
} else if (col.data_type === "numeric" && col.numeric_precision != null) {
typeStr =
col.numeric_scale != null
? `numeric(${col.numeric_precision}, ${col.numeric_scale})`
: `numeric(${col.numeric_precision})`;
} else {
typeStr = col.data_type;
const allSequences = await sql<SequenceDefinition[]>`
SELECT
s.sequence_schema,
s.sequence_name,
s.data_type,
s.start_value,
s.minimum_value,
s.maximum_value,
s.increment,
s.cycle_option,
tblns.nspname AS owner_schema,
tbl.relname AS owner_table,
attr.attname AS owner_column
FROM information_schema.sequences s
JOIN pg_class seq ON seq.relname = s.sequence_name
JOIN pg_namespace n ON n.oid = seq.relnamespace AND n.nspname = s.sequence_schema
LEFT JOIN pg_depend dep ON dep.objid = seq.oid AND dep.deptype = 'a'
LEFT JOIN pg_class tbl ON tbl.oid = dep.refobjid
LEFT JOIN pg_namespace tblns ON tblns.oid = tbl.relnamespace
LEFT JOIN pg_attribute attr ON attr.attrelid = tbl.oid AND attr.attnum = dep.refobjsubid
WHERE ${sql.unsafe(nonSystemSchemaPredicate("s.sequence_schema"))}
ORDER BY s.sequence_schema, s.sequence_name
`;
const sequences = allSequences.filter(
(seq) => !seq.owner_table || includedTableNames.has(tableKey(seq.owner_schema ?? "public", seq.owner_table)),
);
const schemas = new Set<string>(includedSchemas);
for (const seq of sequences) schemas.add(seq.sequence_schema);
const extraSchemas = [...schemas].filter((schemaName) => schemaName !== "public");
if (extraSchemas.length > 0) {
emit("-- Schemas");
for (const schemaName of extraSchemas) {
emitStatement(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(schemaName)};`);
}
emit("");
}
for (const e of enums) {
const labels = e.labels.map((l) => `'${l.replace(/'/g, "''")}'`).join(", ");
emitStatement(`CREATE TYPE ${quoteQualifiedName(e.schema_name, e.typname)} AS ENUM (${labels});`);
}
if (enums.length > 0) emit("");
const extensions = await sql<ExtensionDefinition[]>`
SELECT
e.extname AS extension_name,
n.nspname AS schema_name
FROM pg_extension e
JOIN pg_namespace n ON n.oid = e.extnamespace
WHERE e.extname <> 'plpgsql'
ORDER BY e.extname
`;
if (extensions.length > 0) {
emit("-- Extensions");
for (const extension of extensions) {
emitStatement(
`CREATE EXTENSION IF NOT EXISTS ${quoteIdentifier(extension.extension_name)} WITH SCHEMA ${quoteIdentifier(extension.schema_name)};`,
);
}
emit("");
}
if (sequences.length > 0) {
emit("-- Sequences");
for (const seq of sequences) {
const qualifiedSequenceName = quoteQualifiedName(seq.sequence_schema, seq.sequence_name);
emitStatement(`DROP SEQUENCE IF EXISTS ${qualifiedSequenceName} CASCADE;`);
emitStatement(
`CREATE SEQUENCE ${qualifiedSequenceName} AS ${seq.data_type} INCREMENT BY ${seq.increment} MINVALUE ${seq.minimum_value} MAXVALUE ${seq.maximum_value} START WITH ${seq.start_value}${seq.cycle_option === "YES" ? " CYCLE" : " NO CYCLE"};`,
);
}
emit("");
}
// Get full CREATE TABLE DDL via column info
for (const { schema_name, tablename } of tables) {
const qualifiedTableName = quoteQualifiedName(schema_name, tablename);
const columns = await sql<{
column_name: string;
data_type: string;
udt_schema: string;
udt_name: string;
is_nullable: string;
column_default: string | null;
character_maximum_length: number | null;
numeric_precision: number | null;
numeric_scale: number | null;
}[]>`
SELECT column_name, data_type, udt_schema, udt_name, is_nullable, column_default,
character_maximum_length, numeric_precision, numeric_scale
FROM information_schema.columns
WHERE table_schema = ${schema_name} AND table_name = ${tablename}
ORDER BY ordinal_position
`;
emit(`-- Table: ${schema_name}.${tablename}`);
emitStatement(`DROP TABLE IF EXISTS ${qualifiedTableName} CASCADE;`);
const colDefs: string[] = [];
for (const col of columns) {
let typeStr: string;
if (col.data_type === "USER-DEFINED") {
typeStr = quoteQualifiedName(col.udt_schema, col.udt_name);
} else if (col.data_type === "ARRAY") {
const elementType = col.udt_name.replace(/^_/, "");
typeStr = col.udt_schema === "pg_catalog"
? `${elementType}[]`
: `${quoteQualifiedName(col.udt_schema, elementType)}[]`;
} else if (col.data_type === "character varying") {
typeStr = col.character_maximum_length
? `varchar(${col.character_maximum_length})`
: "varchar";
} else if (col.data_type === "numeric" && col.numeric_precision != null) {
typeStr =
col.numeric_scale != null
? `numeric(${col.numeric_precision}, ${col.numeric_scale})`
: `numeric(${col.numeric_precision})`;
} else {
typeStr = col.data_type;
}
let def = ` "${col.column_name}" ${typeStr}`;
if (col.column_default != null) def += ` DEFAULT ${col.column_default}`;
if (col.is_nullable === "NO") def += " NOT NULL";
colDefs.push(def);
}
let def = ` "${col.column_name}" ${typeStr}`;
if (col.column_default != null) def += ` DEFAULT ${col.column_default}`;
if (col.is_nullable === "NO") def += " NOT NULL";
colDefs.push(def);
// Primary key
const pk = await sql<{ constraint_name: string; column_names: string[] }[]>`
SELECT c.conname AS constraint_name,
array_agg(a.attname ORDER BY array_position(c.conkey, a.attnum)) AS column_names
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey)
WHERE n.nspname = ${schema_name} AND t.relname = ${tablename} AND c.contype = 'p'
GROUP BY c.conname
`;
for (const p of pk) {
const cols = p.column_names.map((c) => `"${c}"`).join(", ");
colDefs.push(` CONSTRAINT "${p.constraint_name}" PRIMARY KEY (${cols})`);
}
emit(`CREATE TABLE ${qualifiedTableName} (`);
emit(colDefs.join(",\n"));
emit(");");
emitStatementBoundary();
emit("");
}
// Primary key
const pk = await sql<{ constraint_name: string; column_names: string[] }[]>`
const ownedSequences = sequences.filter((seq) => seq.owner_table && seq.owner_column);
if (ownedSequences.length > 0) {
emit("-- Sequence ownership");
for (const seq of ownedSequences) {
emitStatement(
`ALTER SEQUENCE ${quoteQualifiedName(seq.sequence_schema, seq.sequence_name)} OWNED BY ${quoteQualifiedName(seq.owner_schema ?? "public", seq.owner_table!)}.${quoteIdentifier(seq.owner_column!)};`,
);
}
emit("");
}
// Unique constraints must exist before foreign keys that reference them.
const allUniqueConstraints = await sql<{
constraint_name: string;
schema_name: string;
tablename: string;
column_names: string[];
}[]>`
SELECT c.conname AS constraint_name,
n.nspname AS schema_name,
t.relname AS tablename,
array_agg(a.attname ORDER BY array_position(c.conkey, a.attnum)) AS column_names
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey)
WHERE n.nspname = ${schema_name} AND t.relname = ${tablename} AND c.contype = 'p'
GROUP BY c.conname
WHERE c.contype = 'u'
AND ${sql.unsafe(nonSystemSchemaPredicate("n.nspname"))}
GROUP BY c.conname, n.nspname, t.relname
ORDER BY n.nspname, t.relname, c.conname
`;
for (const p of pk) {
const cols = p.column_names.map((c) => `"${c}"`).join(", ");
colDefs.push(` CONSTRAINT "${p.constraint_name}" PRIMARY KEY (${cols})`);
}
const uniques = allUniqueConstraints.filter((entry) => includedTableNames.has(tableKey(entry.schema_name, entry.tablename)));
emit(`CREATE TABLE ${qualifiedTableName} (`);
emit(colDefs.join(",\n"));
emit(");");
emitStatementBoundary();
emit("");
}
const ownedSequences = sequences.filter((seq) => seq.owner_table && seq.owner_column);
if (ownedSequences.length > 0) {
emit("-- Sequence ownership");
for (const seq of ownedSequences) {
emitStatement(
`ALTER SEQUENCE ${quoteQualifiedName(seq.sequence_schema, seq.sequence_name)} OWNED BY ${quoteQualifiedName(seq.owner_schema ?? "public", seq.owner_table!)}.${quoteIdentifier(seq.owner_column!)};`,
);
}
emit("");
}
// Unique constraints must exist before foreign keys that reference them.
const allUniqueConstraints = await sql<{
constraint_name: string;
schema_name: string;
tablename: string;
column_names: string[];
}[]>`
SELECT c.conname AS constraint_name,
n.nspname AS schema_name,
t.relname AS tablename,
array_agg(a.attname ORDER BY array_position(c.conkey, a.attnum)) AS column_names
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey)
WHERE c.contype = 'u'
AND ${sql.unsafe(nonSystemSchemaPredicate("n.nspname"))}
GROUP BY c.conname, n.nspname, t.relname
ORDER BY n.nspname, t.relname, c.conname
`;
const uniques = allUniqueConstraints.filter((entry) => includedTableNames.has(tableKey(entry.schema_name, entry.tablename)));
if (uniques.length > 0) {
emit("-- Unique constraints");
for (const u of uniques) {
const cols = u.column_names.map((c) => `"${c}"`).join(", ");
emitStatement(`ALTER TABLE ${quoteQualifiedName(u.schema_name, u.tablename)} ADD CONSTRAINT "${u.constraint_name}" UNIQUE (${cols});`);
}
emit("");
}
// 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;
source_table: string;
source_columns: string[];
target_schema: string;
target_table: string;
target_columns: string[];
update_rule: string;
delete_rule: string;
}[]>`
SELECT
c.conname AS constraint_name,
srcn.nspname AS source_schema,
src.relname AS source_table,
array_agg(sa.attname ORDER BY key_columns.ordinal_position) AS source_columns,
tgtn.nspname AS target_schema,
tgt.relname AS target_table,
array_agg(ta.attname ORDER BY key_columns.ordinal_position) AS target_columns,
CASE c.confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS update_rule,
CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS delete_rule
FROM pg_constraint c
JOIN pg_class src ON src.oid = c.conrelid
JOIN pg_namespace srcn ON srcn.oid = src.relnamespace
JOIN pg_class tgt ON tgt.oid = c.confrelid
JOIN pg_namespace tgtn ON tgtn.oid = tgt.relnamespace
JOIN LATERAL unnest(c.conkey, c.confkey) WITH ORDINALITY AS key_columns(source_attnum, target_attnum, ordinal_position) ON true
JOIN pg_attribute sa ON sa.attrelid = src.oid AND sa.attnum = key_columns.source_attnum
JOIN pg_attribute ta ON ta.attrelid = tgt.oid AND ta.attnum = key_columns.target_attnum
WHERE c.contype = 'f'
AND ${sql.unsafe(nonSystemSchemaPredicate("srcn.nspname"))}
GROUP BY c.conname, srcn.nspname, src.relname, tgtn.nspname, tgt.relname, c.confupdtype, c.confdeltype
ORDER BY srcn.nspname, src.relname, c.conname
`;
const fks = allForeignKeys.filter(
(fk) => includedTableNames.has(tableKey(fk.source_schema, fk.source_table))
&& includedTableNames.has(tableKey(fk.target_schema, fk.target_table)),
);
// 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("");
}
// Indexes (non-primary, non-unique-constraint)
const allIndexes = await sql<{ schema_name: string; tablename: string; indexdef: string }[]>`
SELECT schemaname AS schema_name, tablename, indexdef
FROM pg_indexes
WHERE ${sql.unsafe(nonSystemSchemaPredicate("schemaname"))}
AND indexname NOT IN (
SELECT conname FROM pg_constraint c
JOIN pg_namespace n ON n.oid = c.connamespace
WHERE n.nspname = pg_indexes.schemaname
)
ORDER BY schemaname, tablename, indexname
`;
const indexes = allIndexes.filter((entry) => includedTableNames.has(tableKey(entry.schema_name, entry.tablename)));
if (indexes.length > 0) {
emit("-- Indexes");
for (const idx of indexes) {
emitStatement(`${idx.indexdef};`);
}
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);
const qualifiedTableName = quoteQualifiedName(schema_name, tablename);
const count = await sql.unsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM ${qualifiedTableName}`);
if (excludedTableNames.has(currentTableKey) || (count[0]?.n ?? 0) === 0) continue;
// Get column info for this table
const cols = await sql<{ column_name: string; data_type: string }[]>`
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = ${schema_name} AND table_name = ${tablename}
ORDER BY ordinal_position
`;
const colNames = cols.map((c) => `"${c.column_name}"`).join(", ");
emit(`-- Data for: ${schema_name}.${tablename} (${count[0]!.n} rows)`);
const nullifiedColumns = nullifiedColumnsByTable.get(currentTableKey) ?? new Set<string>();
if (effectiveBackupEngine !== "javascript" && nullifiedColumns.size === 0) {
emit(`COPY ${qualifiedTableName} (${colNames}) FROM stdin;`);
await writer.writeRaw("\n");
const copySql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
try {
const copyStream = await copySql
.unsafe(`COPY ${qualifiedTableName} (${colNames}) TO STDOUT`)
.readable();
for await (const chunk of copyStream) {
await writer.writeRaw(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
}
} finally {
await copySql.end();
if (uniques.length > 0) {
emit("-- Unique constraints");
for (const u of uniques) {
const cols = u.column_names.map((c) => `"${c}"`).join(", ");
emitStatement(`ALTER TABLE ${quoteQualifiedName(u.schema_name, u.tablename)} ADD CONSTRAINT "${u.constraint_name}" UNIQUE (${cols});`);
}
await writer.writeRaw("\\.\n");
emitStatementBoundary();
emit("");
continue;
}
const rowCursor = sql
.unsafe(`SELECT * FROM ${qualifiedTableName}`)
.values()
.cursor(BACKUP_DATA_CURSOR_ROWS) as AsyncIterable<unknown[][]>;
for await (const rows of rowCursor) {
for (const row of rows) {
const values = row.map((rawValue, index) =>
formatSqlValue(rawValue, cols[index]?.column_name, nullifiedColumns, cols[index]?.data_type),
// 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;
source_table: string;
source_columns: string[];
target_schema: string;
target_table: string;
target_columns: string[];
update_rule: string;
delete_rule: string;
}[]>`
SELECT
c.conname AS constraint_name,
srcn.nspname AS source_schema,
src.relname AS source_table,
array_agg(sa.attname ORDER BY key_columns.ordinal_position) AS source_columns,
tgtn.nspname AS target_schema,
tgt.relname AS target_table,
array_agg(ta.attname ORDER BY key_columns.ordinal_position) AS target_columns,
CASE c.confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS update_rule,
CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS delete_rule
FROM pg_constraint c
JOIN pg_class src ON src.oid = c.conrelid
JOIN pg_namespace srcn ON srcn.oid = src.relnamespace
JOIN pg_class tgt ON tgt.oid = c.confrelid
JOIN pg_namespace tgtn ON tgtn.oid = tgt.relnamespace
JOIN LATERAL unnest(c.conkey, c.confkey) WITH ORDINALITY AS key_columns(source_attnum, target_attnum, ordinal_position) ON true
JOIN pg_attribute sa ON sa.attrelid = src.oid AND sa.attnum = key_columns.source_attnum
JOIN pg_attribute ta ON ta.attrelid = tgt.oid AND ta.attnum = key_columns.target_attnum
WHERE c.contype = 'f'
AND ${sql.unsafe(nonSystemSchemaPredicate("srcn.nspname"))}
GROUP BY c.conname, srcn.nspname, src.relname, tgtn.nspname, tgt.relname, c.confupdtype, c.confdeltype
ORDER BY srcn.nspname, src.relname, c.conname
`;
const fks = allForeignKeys.filter(
(fk) => includedTableNames.has(tableKey(fk.source_schema, fk.source_table))
&& includedTableNames.has(tableKey(fk.target_schema, fk.target_table)),
);
// 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("");
}
// Indexes (non-primary, non-unique-constraint)
const allIndexes = await sql<{ schema_name: string; tablename: string; indexdef: string }[]>`
SELECT schemaname AS schema_name, tablename, indexdef
FROM pg_indexes
WHERE ${sql.unsafe(nonSystemSchemaPredicate("schemaname"))}
AND indexname NOT IN (
SELECT conname FROM pg_constraint c
JOIN pg_namespace n ON n.oid = c.connamespace
WHERE n.nspname = pg_indexes.schemaname
)
ORDER BY schemaname, tablename, indexname
`;
const indexes = allIndexes.filter((entry) => includedTableNames.has(tableKey(entry.schema_name, entry.tablename)));
if (indexes.length > 0) {
emit("-- Indexes");
for (const idx of indexes) {
emitStatement(`${idx.indexdef};`);
}
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};`,
);
emitStatement(`INSERT INTO ${qualifiedTableName} (${colNames}) VALUES (${values.join(", ")});`);
}
await writer.drain();
emit("");
}
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("");
}
// Dump data for each table
for (const { schema_name, tablename } of tables) {
const currentTableKey = tableKey(schema_name, tablename);
const qualifiedTableName = quoteQualifiedName(schema_name, tablename);
const count = await sql.unsafe<{ n: string }[]>(`SELECT count(*)::text AS n FROM ${qualifiedTableName}`);
if (excludedTableNames.has(currentTableKey) || (count[0]?.n ?? "0") === "0") continue;
// Sequence values
if (sequences.length > 0) {
emit("-- Sequence values");
for (const seq of sequences) {
const qualifiedSequenceName = quoteQualifiedName(seq.sequence_schema, seq.sequence_name);
const val = await sql.unsafe<{ last_value: string; is_called: boolean }[]>(
`SELECT last_value::text, is_called FROM ${qualifiedSequenceName}`,
);
const skipSequenceValue =
seq.owner_table !== null
&& excludedTableNames.has(seq.owner_table);
if (val[0] && !skipSequenceValue) {
emitStatement(`SELECT setval('${qualifiedSequenceName.replaceAll("'", "''")}', ${val[0].last_value}, ${val[0].is_called ? "true" : "false"});`);
// Get column info for this table
const cols = await sql<{ column_name: string; data_type: string }[]>`
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = ${schema_name} AND table_name = ${tablename}
ORDER BY ordinal_position
`;
const colNames = cols.map((c) => `"${c.column_name}"`).join(", ");
emit(`-- Data for: ${schema_name}.${tablename} (${count[0]!.n} rows)`);
const nullifiedColumns = nullifiedColumnsByTable.get(currentTableKey) ?? new Set<string>();
// Use the bounded row cursor for every JavaScript export, including
// filtered auto-mode backups. A separate COPY connection would not
// share this snapshot; postgres.js COPY on the transaction connection
// can stall after its stream ends, before the next statement executes.
const rowCursor = sql
.unsafe(`SELECT * FROM ${qualifiedTableName}`)
.values()
.cursor(BACKUP_DATA_CURSOR_ROWS) as AsyncIterable<unknown[][]>;
for await (const rows of rowCursor) {
for (const row of rows) {
const values = row.map((rawValue, index) =>
formatSqlValue(rawValue, cols[index]?.column_name, nullifiedColumns, cols[index]?.data_type),
);
emitStatement(`INSERT INTO ${qualifiedTableName} (${colNames}) VALUES (${values.join(", ")});`);
}
await writer.drain();
}
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");
for (const seq of sequences) {
const qualifiedSequenceName = quoteQualifiedName(seq.sequence_schema, seq.sequence_name);
const val = await sql.unsafe<{ last_value: string; is_called: boolean }[]>(
`SELECT last_value::text, is_called FROM ${qualifiedSequenceName}`,
);
const skipSequenceValue =
seq.owner_table !== null
&& excludedTableNames.has(seq.owner_table);
if (val[0] && !skipSequenceValue) {
emitStatement(`SELECT setval('${qualifiedSequenceName.replaceAll("'", "''")}', ${val[0].last_value}, ${val[0].is_called ? "true" : "false"});`);
}
}
emit("");
}
emitStatement("COMMIT;");
emit("");
}
emitStatement("COMMIT;");
emit("");
await writer.close();
await writer.close();
});
// Compress the SQL file with gzip
const sqlReadStream = createReadStream(sqlFile);
+9
View File
@@ -0,0 +1,9 @@
import { customType } from "drizzle-orm/pg-core";
/** Historical serial identities retain their exact value. New identities are
* random UUIDs; the abandoned serial sequence is never advanced again. */
export const eventIdentity = customType<{ data: string | number; driverData: string }>({
dataType: () => "text",
toDriver: value => String(value),
fromDriver: value => /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : value,
});
+1
View File
@@ -22,6 +22,7 @@ export {
} from "./test-embedded-postgres.js";
export {
runDatabaseBackup,
withDatabaseBackupSnapshot,
runDatabaseRestore,
formatDatabaseBackupResult,
type BackupRetentionPolicy,
@@ -0,0 +1,51 @@
ALTER TABLE "heartbeat_runs" ADD CONSTRAINT "heartbeat_runs_company_id_run_id_uq" UNIQUE("company_id","id");--> statement-breakpoint
CREATE TABLE "native_authority_records" (
"company_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"normalized_session_id" text NOT NULL,
"run_id" uuid NOT NULL,
"kind" text NOT NULL,
"record_id" text NOT NULL,
"sequence" bigint NOT NULL,
"body" text NOT NULL,
"body_sha256" text NOT NULL,
CONSTRAINT "native_authority_records_company_id_normalized_session_id_run_id_kind_record_id_pk" PRIMARY KEY("company_id","normalized_session_id","run_id","kind","record_id"),
CONSTRAINT "native_authority_records_sequence_uq" UNIQUE("company_id","normalized_session_id","run_id","kind","sequence"),
CONSTRAINT "native_authority_records_body_bound" CHECK (octet_length("native_authority_records"."body") <= 1048576),
CONSTRAINT "native_authority_records_kind_valid" CHECK ("native_authority_records"."kind" in ('command', 'event', 'effect')),
CONSTRAINT "native_authority_records_sequence_positive" CHECK ("native_authority_records"."sequence" > 0)
);
--> statement-breakpoint
CREATE TABLE "native_session_authorities" (
"company_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"normalized_session_id" text NOT NULL,
"run_id" uuid NOT NULL,
"successor_run_id" uuid,
"binding" text NOT NULL,
"generation" bigint NOT NULL,
"state" text NOT NULL,
"state_sha256" text NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "native_session_authorities_company_id_normalized_session_id_run_id_pk" PRIMARY KEY("company_id","normalized_session_id","run_id"),
CONSTRAINT "native_session_authorities_owner_uq" UNIQUE("company_id","issue_id","normalized_session_id","run_id"),
CONSTRAINT "native_session_authorities_state_bound" CHECK (octet_length("native_session_authorities"."state") <= 16777216),
CONSTRAINT "native_session_authorities_generation_positive" CHECK ("native_session_authorities"."generation" > 0)
);
--> statement-breakpoint
CREATE TABLE "native_source_cursors" (
"company_id" uuid NOT NULL,
"run_id" uuid NOT NULL,
"source_instance_id" text NOT NULL,
"cursor" bigint DEFAULT 0 NOT NULL,
CONSTRAINT "native_source_cursors_run_id_source_instance_id_pk" PRIMARY KEY("run_id","source_instance_id"),
CONSTRAINT "native_source_cursors_cursor_positive" CHECK ("native_source_cursors"."cursor" >= 0)
);
--> statement-breakpoint
ALTER TABLE "native_authority_records" ADD CONSTRAINT "native_authority_records_authority_owner_fk" FOREIGN KEY ("company_id","issue_id","normalized_session_id","run_id") REFERENCES "public"."native_session_authorities"("company_id","issue_id","normalized_session_id","run_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "native_authority_records" ADD CONSTRAINT "native_authority_records_run_owner_fk" FOREIGN KEY ("company_id","issue_id","run_id") REFERENCES "public"."heartbeat_runs"("company_id","native_issue_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "native_session_authorities" ADD CONSTRAINT "native_session_authorities_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "native_session_authorities" ADD CONSTRAINT "native_session_authorities_issue_owner_fk" FOREIGN KEY ("company_id","issue_id") REFERENCES "public"."issues"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "native_session_authorities" ADD CONSTRAINT "native_session_authorities_run_owner_fk" FOREIGN KEY ("company_id","issue_id","run_id") REFERENCES "public"."heartbeat_runs"("company_id","native_issue_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "native_source_cursors" ADD CONSTRAINT "native_source_cursors_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "native_source_cursors" ADD CONSTRAINT "native_source_cursors_run_owner_fk" FOREIGN KEY ("company_id","run_id") REFERENCES "public"."heartbeat_runs"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
@@ -0,0 +1 @@
CREATE UNIQUE INDEX "native_authority_records_session_effect_uq" ON "native_authority_records" USING btree ("company_id","normalized_session_id","record_id") WHERE "native_authority_records"."kind" = 'effect';
@@ -0,0 +1,15 @@
CREATE TABLE "native_authority_work" (
"company_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"normalized_session_id" text NOT NULL,
"run_id" uuid NOT NULL,
"collection" text NOT NULL,
"work_id" text NOT NULL,
"body" text NOT NULL,
"body_sha256" text NOT NULL,
CONSTRAINT "native_authority_work_company_id_normalized_session_id_collection_work_id_pk" PRIMARY KEY("company_id","normalized_session_id","collection","work_id"),
CONSTRAINT "native_authority_work_body_bound" CHECK (octet_length("native_authority_work"."body") <= 16384),
CONSTRAINT "native_authority_work_collection_valid" CHECK ("native_authority_work"."collection" = 'process-owner')
);
--> statement-breakpoint
ALTER TABLE "native_authority_work" ADD CONSTRAINT "native_authority_work_authority_owner_fk" FOREIGN KEY ("company_id","issue_id","normalized_session_id","run_id") REFERENCES "public"."native_session_authorities"("company_id","issue_id","normalized_session_id","run_id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,13 @@
CREATE TABLE "native_output_body_chunks" (
"company_id" uuid NOT NULL,
"run_id" uuid NOT NULL,
"body_id" text NOT NULL,
"source_seq" bigint NOT NULL,
"event_id" bigint NOT NULL,
CONSTRAINT "native_output_body_chunks_company_id_run_id_body_id_source_seq_pk" PRIMARY KEY("company_id","run_id","body_id","source_seq"),
CONSTRAINT "native_output_body_chunks_body_id_valid" CHECK ("native_output_body_chunks"."body_id" ~ '^[a-f0-9]{64}$'),
CONSTRAINT "native_output_body_chunks_sequence_positive" CHECK ("native_output_body_chunks"."source_seq" > 0)
);
--> statement-breakpoint
ALTER TABLE "native_output_body_chunks" ADD CONSTRAINT "native_output_body_chunks_event_id_heartbeat_run_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."heartbeat_run_events"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "native_output_body_chunks" ADD CONSTRAINT "native_output_body_chunks_run_owner_fk" FOREIGN KEY ("company_id","run_id") REFERENCES "public"."heartbeat_runs"("company_id","id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,13 @@
CREATE TABLE "native_run_process_evidence" (
"company_id" uuid NOT NULL,
"run_id" uuid NOT NULL,
"seq" bigint NOT NULL,
"event_type" text,
"process_pid" integer,
"process_group_id" integer,
CONSTRAINT "native_run_process_evidence_company_id_run_id_pk" PRIMARY KEY("company_id","run_id"),
CONSTRAINT "native_run_process_evidence_event_valid" CHECK (("native_run_process_evidence"."seq" = 0 and "native_run_process_evidence"."event_type" is null) or ("native_run_process_evidence"."seq" > 0 and "native_run_process_evidence"."event_type" is not null and "native_run_process_evidence"."event_type" in ('native.process_start_requested', 'native.process_identity_recorded', 'native.local_process_stopped')))
);
--> statement-breakpoint
ALTER TABLE "native_run_process_evidence" ADD CONSTRAINT "native_run_process_evidence_owner_fk" FOREIGN KEY ("company_id","run_id") REFERENCES "public"."heartbeat_runs"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "native_output_body_chunks_event_idx" ON "native_output_body_chunks" USING btree ("event_id");
@@ -0,0 +1,3 @@
ALTER TABLE "native_authority_records" ADD COLUMN "body_encoding" text DEFAULT 'json' NOT NULL;--> statement-breakpoint
ALTER TABLE "native_authority_records" ADD COLUMN "body_bytes" bigint;--> statement-breakpoint
ALTER TABLE "native_authority_records" ADD CONSTRAINT "native_authority_records_body_encoding_valid" CHECK (("native_authority_records"."body_encoding" = 'json' AND "native_authority_records"."body_bytes" IS NULL) OR ("native_authority_records"."body_encoding" = 'object.v1' AND "native_authority_records"."body_bytes" IS NOT NULL AND "native_authority_records"."body_bytes" BETWEEN 1 AND 1048576));
@@ -0,0 +1 @@
ALTER TABLE "heartbeat_runs" ALTER COLUMN "last_output_seq" SET DATA TYPE bigint;
@@ -0,0 +1,5 @@
ALTER TABLE "native_session_authorities" DROP CONSTRAINT "native_session_authorities_generation_positive";--> statement-breakpoint
ALTER TABLE "native_session_authorities" ALTER COLUMN "generation" SET DATA TYPE text;--> statement-breakpoint
ALTER TABLE "native_session_authorities" ADD COLUMN "committed_from" text;--> statement-breakpoint
ALTER TABLE "native_session_authorities" ADD CONSTRAINT "native_session_authorities_predecessor_valid" CHECK (("native_session_authorities"."generation" NOT LIKE 'r:%' OR "native_session_authorities"."committed_from" IS NOT NULL) AND ("native_session_authorities"."committed_from" IS NULL OR ("native_session_authorities"."committed_from" <> "native_session_authorities"."generation" AND "native_session_authorities"."committed_from" ~ '^(r:[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}|0|[1-9][0-9]{0,18})$')));--> statement-breakpoint
ALTER TABLE "native_session_authorities" ADD CONSTRAINT "native_session_authorities_generation_positive" CHECK ("native_session_authorities"."generation" ~ '^(r:[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}|[1-9][0-9]{0,18})$');
@@ -0,0 +1,4 @@
ALTER TABLE "native_authority_records" DROP CONSTRAINT "native_authority_records_sequence_uq";--> statement-breakpoint
ALTER TABLE "native_authority_records" DROP CONSTRAINT "native_authority_records_sequence_positive";--> statement-breakpoint
CREATE UNIQUE INDEX "native_authority_records_sequence_uq" ON "native_authority_records" USING btree ("company_id","normalized_session_id","run_id","kind","sequence") WHERE "native_authority_records"."kind" <> 'effect';--> statement-breakpoint
ALTER TABLE "native_authority_records" ADD CONSTRAINT "native_authority_records_sequence_positive" CHECK ("native_authority_records"."sequence" > 0 OR ("native_authority_records"."kind" = 'effect' AND "native_authority_records"."sequence" = 0));
@@ -0,0 +1,4 @@
DROP INDEX "native_authority_records_sequence_uq";--> statement-breakpoint
ALTER TABLE "native_authority_records" ADD COLUMN "sequence_epoch" text DEFAULT '' NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "native_authority_records_sequence_uq" ON "native_authority_records" USING btree ("company_id","normalized_session_id","run_id","kind","sequence_epoch","sequence") WHERE "native_authority_records"."kind" <> 'effect';--> statement-breakpoint
ALTER TABLE "native_authority_records" ADD CONSTRAINT "native_authority_records_sequence_epoch_valid" CHECK ("native_authority_records"."sequence_epoch" = '' OR ("native_authority_records"."kind" = 'command' AND "native_authority_records"."sequence_epoch" ~ '^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$'));
@@ -0,0 +1,2 @@
ALTER TABLE "native_authority_records" DROP CONSTRAINT "native_authority_records_sequence_epoch_valid";--> statement-breakpoint
ALTER TABLE "native_authority_records" ADD CONSTRAINT "native_authority_records_sequence_epoch_valid" CHECK ("native_authority_records"."sequence_epoch" = '' OR ("native_authority_records"."kind" IN ('command', 'event') AND "native_authority_records"."sequence_epoch" ~ '^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$'));
@@ -0,0 +1,45 @@
CREATE TABLE "native_source_epochs" (
"company_id" uuid NOT NULL,
"run_id" uuid NOT NULL,
"source_instance_id" text NOT NULL,
"from_epoch" text NOT NULL,
"next_epoch" uuid NOT NULL,
"transition_id" uuid NOT NULL,
"final_ordinal" bigint NOT NULL,
"transition" jsonb NOT NULL,
CONSTRAINT "native_source_epochs_run_id_source_instance_id_from_epoch_pk" PRIMARY KEY("run_id","source_instance_id","from_epoch"),
CONSTRAINT "native_source_epochs_ordinal_bound" CHECK ("native_source_epochs"."final_ordinal" > 0 and "native_source_epochs"."final_ordinal" <= 9007199254740991)
);
--> statement-breakpoint
DROP INDEX "heartbeat_run_events_run_source_seq_uq";--> statement-breakpoint
ALTER TABLE "native_output_body_chunks" DROP CONSTRAINT "native_output_body_chunks_company_id_run_id_body_id_source_seq_pk";--> statement-breakpoint
ALTER TABLE "heartbeat_run_events" ADD COLUMN "source_epoch" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "native_output_body_chunks" ADD COLUMN "chunk_offset" bigint;--> statement-breakpoint
ALTER TABLE "native_output_body_chunks" ADD COLUMN "chunk_sha256" text;--> statement-breakpoint
-- Backfill body-local locators from their canonical immutable event. Refuse
-- conflicting historical chunks; only exact duplicate content may coalesce.
UPDATE native_output_body_chunks c SET
chunk_offset = (e.payload->'prpEvent'->'payload'->>'offset')::bigint,
chunk_sha256 = e.payload->'prpEvent'->'payload'->>'sha256'
FROM heartbeat_run_events e WHERE e.id = c.event_id AND e.company_id = c.company_id AND e.run_id = c.run_id;
--> statement-breakpoint
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM native_output_body_chunks GROUP BY company_id, run_id, body_id, chunk_offset HAVING count(DISTINCT chunk_sha256) > 1)
THEN RAISE EXCEPTION 'Conflicting historical output chunks'; END IF;
END $$;
--> statement-breakpoint
DELETE FROM native_output_body_chunks a USING native_output_body_chunks b
WHERE a.company_id=b.company_id AND a.run_id=b.run_id AND a.body_id=b.body_id AND a.chunk_offset=b.chunk_offset
AND a.chunk_sha256=b.chunk_sha256 AND a.event_id>b.event_id;
--> statement-breakpoint
ALTER TABLE native_output_body_chunks ALTER COLUMN chunk_offset SET NOT NULL, ALTER COLUMN chunk_sha256 SET NOT NULL;
--> statement-breakpoint
ALTER TABLE "native_output_body_chunks" ADD CONSTRAINT "native_output_body_chunks_company_id_run_id_body_id_chunk_offset_pk" PRIMARY KEY("company_id","run_id","body_id","chunk_offset");--> statement-breakpoint
ALTER TABLE "native_source_cursors" ADD COLUMN "source_epoch" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "native_source_epochs" ADD CONSTRAINT "native_source_epochs_run_owner_fk" FOREIGN KEY ("company_id","run_id") REFERENCES "public"."heartbeat_runs"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "native_source_epochs_successor_uq" ON "native_source_epochs" USING btree ("run_id","source_instance_id","next_epoch");--> statement-breakpoint
CREATE UNIQUE INDEX "native_source_epochs_transition_uq" ON "native_source_epochs" USING btree ("run_id","source_instance_id","transition_id");--> statement-breakpoint
-- paperclip:migration-safety-ignore large-create-index-not-concurrently: Replaces the existing exact replay uniqueness index atomically under the transactional migration runner; CONCURRENTLY cannot run here. This one-time maintenance migration must finish before admitting epoch writers; ordinary continuation never rebuilds it.
CREATE UNIQUE INDEX "heartbeat_run_events_run_source_seq_uq" ON "heartbeat_run_events" USING btree ("run_id","source_instance_id","source_epoch","source_seq") WHERE "heartbeat_run_events"."source_instance_id" is not null and "heartbeat_run_events"."source_seq" is not null;--> statement-breakpoint
ALTER TABLE "native_output_body_chunks" ADD CONSTRAINT "native_output_body_chunks_offset_bound" CHECK ("native_output_body_chunks"."chunk_offset" >= 0 and "native_output_body_chunks"."chunk_offset" < 4194304);--> statement-breakpoint
ALTER TABLE "native_output_body_chunks" ADD CONSTRAINT "native_output_body_chunks_hash_valid" CHECK ("native_output_body_chunks"."chunk_sha256" ~ '^[a-f0-9]{64}$');
@@ -0,0 +1 @@
ALTER TABLE "agent_task_sessions" ADD COLUMN "goal_source_epoch" text;
@@ -0,0 +1 @@
ALTER TABLE "agent_task_sessions" ADD COLUMN "goal_revision_token" text;
@@ -0,0 +1,104 @@
CREATE TABLE "heartbeat_run_event_epochs" (
"company_id" uuid NOT NULL,
"run_id" uuid NOT NULL,
"epoch" text NOT NULL,
"next_epoch" uuid NOT NULL,
"final_seq" bigint NOT NULL,
CONSTRAINT "heartbeat_run_event_epochs_run_id_epoch_pk" PRIMARY KEY("run_id","epoch")
);
--> statement-breakpoint
CREATE TABLE "heartbeat_run_event_heads" (
"company_id" uuid NOT NULL,
"run_id" uuid NOT NULL,
"lane" text NOT NULL,
"event_id" text NOT NULL,
CONSTRAINT "heartbeat_run_event_heads_run_id_lane_pk" PRIMARY KEY("run_id","lane")
);
--> statement-breakpoint
CREATE TABLE "heartbeat_run_event_links" (
"company_id" uuid NOT NULL,
"run_id" uuid NOT NULL,
"lane" text NOT NULL,
"event_id" text NOT NULL,
"previous_id" text,
CONSTRAINT "heartbeat_run_event_links_run_id_lane_event_id_pk" PRIMARY KEY("run_id","lane","event_id")
);
--> statement-breakpoint
DROP INDEX "heartbeat_run_events_run_seq_uq";--> statement-breakpoint
ALTER TABLE "native_output_body_chunks" DROP CONSTRAINT "native_output_body_chunks_event_id_heartbeat_run_events_id_fk";--> statement-breakpoint
ALTER TABLE "heartbeat_run_events" ALTER COLUMN "id" DROP DEFAULT;--> statement-breakpoint
ALTER TABLE "heartbeat_run_events" ALTER COLUMN "id" SET DATA TYPE text USING id::text;--> statement-breakpoint
ALTER TABLE "heartbeat_run_events" ALTER COLUMN "id" SET DEFAULT gen_random_uuid()::text;--> statement-breakpoint
ALTER TABLE "native_output_body_chunks" ALTER COLUMN "event_id" SET DATA TYPE text USING event_id::text;--> statement-breakpoint
ALTER TABLE "native_output_body_chunks" ADD CONSTRAINT "native_output_body_chunks_event_id_heartbeat_run_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "heartbeat_run_events"("id") ON DELETE cascade;--> statement-breakpoint
ALTER TABLE "heartbeat_run_events" ADD COLUMN "event_epoch" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "heartbeat_runs" ADD COLUMN "event_epoch" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "heartbeat_run_event_epochs" ADD CONSTRAINT "heartbeat_run_event_epochs_company_id_run_id_heartbeat_runs_company_id_id_fk" FOREIGN KEY ("company_id","run_id") REFERENCES "public"."heartbeat_runs"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "heartbeat_run_event_heads" ADD CONSTRAINT "heartbeat_run_event_heads_event_id_heartbeat_run_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."heartbeat_run_events"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "heartbeat_run_event_heads" ADD CONSTRAINT "heartbeat_run_event_heads_company_id_run_id_heartbeat_runs_company_id_id_fk" FOREIGN KEY ("company_id","run_id") REFERENCES "public"."heartbeat_runs"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "heartbeat_run_event_links" ADD CONSTRAINT "heartbeat_run_event_links_event_id_heartbeat_run_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."heartbeat_run_events"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "heartbeat_run_event_links" ADD CONSTRAINT "heartbeat_run_event_links_company_id_run_id_heartbeat_runs_company_id_id_fk" FOREIGN KEY ("company_id","run_id") REFERENCES "public"."heartbeat_runs"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "heartbeat_run_event_epochs_successor_uq" ON "heartbeat_run_event_epochs" USING btree ("run_id","next_epoch");--> statement-breakpoint
-- paperclip:migration-safety-ignore large-create-index-not-concurrently: The serial-to-opaque identity migration and epoch-key replacement run atomically under the migrator transaction; schedule a maintenance window for its one-time history-sized index/backfill cost. CONCURRENTLY is not legal inside this transaction.
CREATE UNIQUE INDEX "heartbeat_run_events_run_seq_uq" ON "heartbeat_run_events" USING btree ("run_id","event_epoch","seq");
--> statement-breakpoint
-- Fixed-size semantic lane names. SHA-256 is built into PostgreSQL; no extension.
CREATE FUNCTION paperclip_run_event_lanes(kind text, body jsonb, source_id text, message text, source_instance text)
RETURNS text[] LANGUAGE sql IMMUTABLE AS $fn$
SELECT array_remove(ARRAY[
'all',
'type:' || encode(sha256(convert_to(kind, 'UTF8')), 'hex'),
CASE WHEN kind IN ('native.process_start_requested', 'native.process_identity_recorded', 'native.local_process_stopped') AND source_id IS NULL THEN 'native-process' END,
CASE WHEN kind IN ('workspace.change.updated', 'workspace.diff.recorded') THEN 'workspace-diff' END,
CASE WHEN kind IN ('run.result.proposed', 'run.terminal') AND source_instance IS NOT NULL
THEN 'settlement:' || encode(sha256(convert_to(octet_length(source_instance)::text || ':' || source_instance || ':' || coalesce(body #>> '{prpEvent,turnId}', ''), 'UTF8')), 'hex') END,
CASE WHEN kind IN ('session.started', 'session.resumed', 'session.reconciled') AND source_instance IS NOT NULL
THEN 'provider-session:' || encode(sha256(convert_to(source_instance, 'UTF8')), 'hex') END,
CASE WHEN kind IN ('workspace.ready', 'research.started', 'research.progressed', 'research.completed', 'tool.execution.started', 'tool.execution.progressed', 'tool.execution.completed', 'item.completed', 'delegation.started', 'delegation.updated', 'delegation.completed', 'workspace.change.updated', 'workspace.diff.recorded', 'workspace.file.referenced', 'artifact.generated') THEN 'safe-native-progress' END,
CASE WHEN kind IN ('runtime_request.created', 'runtime_request.resolved', 'runtime_request.cancelled', 'runtime_request.expired') AND coalesce(body #>> '{prpEvent,payload,request,requestId}', body #>> '{prpEvent,payload,requestId}', '') <> ''
THEN 'request:' || encode(sha256(convert_to(coalesce(body #>> '{prpEvent,payload,request,requestId}', body #>> '{prpEvent,payload,requestId}'), 'UTF8')), 'hex') END,
CASE WHEN kind = 'item.completed' OR (kind = 'lifecycle' AND body->>'retryReasonCode' = 'semantic_result_missing') THEN 'presentation' END,
CASE WHEN kind = 'lifecycle' AND message LIKE 'Bounded retry exhausted%' THEN 'exhaustion' END,
CASE WHEN kind = 'lifecycle' AND message LIKE 'Bounded retry exhausted%'
AND jsonb_typeof(body->'retryReason') = 'string' AND jsonb_typeof(body->'scheduledRetryAttempt') = 'number' AND jsonb_typeof(body->'maxAttempts') = 'number'
THEN 'exhaustion-receipt:' || encode(sha256(convert_to(octet_length(body->>'retryReason')::text || ':' || (body->>'retryReason') || ':' || (body->>'scheduledRetryAttempt') || ':' || (body->>'maxAttempts'), 'UTF8')), 'hex') END,
CASE WHEN kind IN ('harness.ready', 'session.started', 'session.resumed', 'session.updated', 'turn.started', 'provider.event', 'provider.rpc_result') THEN 'provider-identity' END
], NULL)
$fn$;
--> statement-breakpoint
-- Historical migration is deliberately O(history), under the migration lock.
-- New appends maintain these indexes in the same transaction as their event.
INSERT INTO heartbeat_run_event_links (company_id, run_id, lane, event_id, previous_id)
SELECT company_id, run_id, lane, id,
lag(id) OVER (PARTITION BY run_id, lane ORDER BY seq)
FROM heartbeat_run_events
CROSS JOIN LATERAL unnest(paperclip_run_event_lanes(event_type, payload, source_event_id, message, source_instance_id)) AS lanes(lane);
--> statement-breakpoint
INSERT INTO heartbeat_run_event_heads (company_id, run_id, lane, event_id)
SELECT DISTINCT ON (e.run_id, link.lane) e.company_id, e.run_id, link.lane, e.id
FROM heartbeat_run_events e
JOIN heartbeat_run_event_links link ON link.run_id = e.run_id AND link.event_id = e.id
ORDER BY e.run_id, link.lane, e.seq DESC;
--> statement-breakpoint
CREATE FUNCTION paperclip_index_run_event() RETURNS trigger LANGUAGE plpgsql AS $fn$
DECLARE lane_key text; prior_id text; owner_company uuid; owner_agent uuid; owner_epoch text;
BEGIN
SELECT company_id, agent_id, event_epoch INTO owner_company, owner_agent, owner_epoch
FROM heartbeat_runs WHERE id = NEW.run_id FOR UPDATE;
IF owner_company IS DISTINCT FROM NEW.company_id OR owner_agent IS DISTINCT FROM NEW.agent_id OR owner_epoch IS DISTINCT FROM NEW.event_epoch THEN
RAISE EXCEPTION 'heartbeat_run_event_binding_mismatch';
END IF;
FOREACH lane_key IN ARRAY paperclip_run_event_lanes(NEW.event_type, NEW.payload, NEW.source_event_id, NEW.message, NEW.source_instance_id) LOOP
SELECT event_id INTO prior_id FROM heartbeat_run_event_heads WHERE run_id = NEW.run_id AND lane = lane_key;
INSERT INTO heartbeat_run_event_links (company_id, run_id, lane, event_id, previous_id)
VALUES (NEW.company_id, NEW.run_id, lane_key, NEW.id, prior_id);
INSERT INTO heartbeat_run_event_heads (company_id, run_id, lane, event_id)
VALUES (NEW.company_id, NEW.run_id, lane_key, NEW.id)
ON CONFLICT (run_id, lane) DO UPDATE SET event_id = EXCLUDED.event_id;
END LOOP;
RETURN NEW;
END
$fn$;
--> statement-breakpoint
CREATE TRIGGER paperclip_run_event_index AFTER INSERT ON heartbeat_run_events
FOR EACH ROW EXECUTE FUNCTION paperclip_index_run_event();
@@ -0,0 +1,93 @@
CREATE TABLE "native_tool_receipt_references" (
"company_id" uuid NOT NULL,
"run_id" uuid NOT NULL,
"kind" text NOT NULL,
"target" text NOT NULL,
"key_sha256" text NOT NULL,
CONSTRAINT "native_tool_receipt_references_pk" PRIMARY KEY("company_id","run_id","kind","target","key_sha256")
);
--> statement-breakpoint
CREATE TABLE "native_tool_receipts" (
"company_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"run_id" uuid NOT NULL,
"key_sha256" text NOT NULL,
"idempotency_key" text NOT NULL,
"receipt" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "native_tool_receipts_pk" PRIMARY KEY("company_id","run_id","key_sha256")
);
--> statement-breakpoint
ALTER TABLE "native_tool_receipt_references" ADD CONSTRAINT "native_tool_receipt_references_receipt_fk" FOREIGN KEY ("company_id","run_id","key_sha256") REFERENCES "public"."native_tool_receipts"("company_id","run_id","key_sha256") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "native_tool_receipts" ADD CONSTRAINT "native_tool_receipts_run_owner_fk" FOREIGN KEY ("company_id","issue_id","run_id") REFERENCES "public"."heartbeat_runs"("company_id","native_issue_id","id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
-- Migrate exact business receipts once; foreground writes never rebuild a map.
-- Preserve every value, including malformed legacy values, so corruption cannot
-- become an absent receipt that authorizes repeating an effect.
INSERT INTO native_tool_receipts(company_id, issue_id, run_id, key_sha256, idempotency_key, receipt)
SELECT r.company_id, r.native_issue_id, r.id,
encode(sha256(convert_to(entry.key, 'UTF8')), 'hex'), entry.key, entry.value
FROM heartbeat_runs r
CROSS JOIN LATERAL jsonb_each(CASE WHEN jsonb_typeof(r.result_json->'semanticToolReceipts') = 'object'
THEN r.result_json->'semanticToolReceipts' ELSE '{}'::jsonb END) entry
WHERE r.runtime_mode = 'native' AND r.native_issue_id IS NOT NULL;
--> statement-breakpoint
INSERT INTO native_tool_receipt_references(company_id, run_id, kind, target, key_sha256)
SELECT r.company_id, r.run_id, 'generated-comment', r.receipt->'result'->>'commentId', r.key_sha256
FROM native_tool_receipts r
WHERE r.receipt->>'operationId' = 'report_progress'
AND r.receipt->'result'->>'commentId' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
ON CONFLICT DO NOTHING;
--> statement-breakpoint
INSERT INTO native_tool_receipt_references(company_id, run_id, kind, target, key_sha256)
SELECT r.company_id, r.run_id, 'publication', r.receipt->'result'->'entityRefs'->>0, r.key_sha256
FROM native_tool_receipts r
WHERE r.receipt->>'operationId' IN ('register_deliverable', 'reuse_chat_attachment')
AND r.receipt->'result'->>'disposition' IN ('applied', 'duplicate')
AND r.receipt->'result'->'entityRefs'->>0 ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
AND (
(r.receipt->>'operationId' = 'register_deliverable'
AND r.receipt->'result'->>'attachmentId' = r.receipt->'result'->'entityRefs'->>0
AND r.receipt->'result'->>'commandId' = 'deliverable-prepared:' || (r.receipt->'result'->>'attachmentId'))
OR
(r.receipt->>'operationId' = 'reuse_chat_attachment'
AND r.receipt->'result'->'prepared'->>'attachmentId' = r.receipt->'result'->'entityRefs'->>0
AND r.receipt->'result'->>'commandId' = 'chat-attachment-reused:' || (r.receipt->'result'->'prepared'->>'attachmentId'))
)
ON CONFLICT DO NOTHING;
--> statement-breakpoint
INSERT INTO native_tool_receipt_references(company_id, run_id, kind, target, key_sha256)
SELECT r.company_id, r.run_id, 'generated-comment', ref.value, r.key_sha256
FROM native_tool_receipts r
CROSS JOIN LATERAL jsonb_array_elements_text(CASE WHEN jsonb_typeof(r.receipt->'result'->'entityRefs') = 'array'
THEN r.receipt->'result'->'entityRefs' ELSE '[]'::jsonb END) ref
WHERE r.receipt->>'operationId' IN ('register_deliverable', 'reuse_chat_attachment')
AND r.receipt->'result'->>'disposition' IN ('applied', 'duplicate')
AND ref.value ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
AND (
(r.receipt->>'operationId' = 'register_deliverable'
AND r.receipt->'result'->>'attachmentId' = r.receipt->'result'->'entityRefs'->>0
AND r.receipt->'result'->>'commandId' = 'deliverable-prepared:' || (r.receipt->'result'->>'attachmentId'))
OR
(r.receipt->>'operationId' = 'reuse_chat_attachment'
AND r.receipt->'result'->'prepared'->>'attachmentId' = r.receipt->'result'->'entityRefs'->>0
AND r.receipt->'result'->>'commandId' = 'chat-attachment-reused:' || (r.receipt->'result'->'prepared'->>'attachmentId')
AND ref.value = r.receipt->'result'->'prepared'->>'commentId'
AND r.receipt->'result'->'prepared'->>'commentId' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')
)
ON CONFLICT DO NOTHING;
--> statement-breakpoint
INSERT INTO native_tool_receipt_references(company_id, run_id, kind, target, key_sha256)
SELECT r.company_id, r.run_id, 'attachment-reuse',
(r.receipt->'input'->>'sourceCommentId') || '/' || (r.receipt->'input'->>'attachmentId'), r.key_sha256
FROM native_tool_receipts r
WHERE r.receipt->>'operationId' = 'reuse_chat_attachment'
-- Keep the duplicate-effect fence even for a damaged legacy result; runtime
-- lookup fails closed on its result rather than admitting the same reuse.
AND r.receipt->'input'->>'sourceCommentId' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
AND r.receipt->'input'->>'attachmentId' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
ON CONFLICT DO NOTHING;
--> statement-breakpoint
UPDATE heartbeat_runs SET result_json = result_json - 'semanticToolReceipts'
WHERE runtime_mode = 'native' AND native_issue_id IS NOT NULL
AND jsonb_typeof(result_json->'semanticToolReceipts') = 'object';
@@ -0,0 +1,14 @@
ALTER TABLE "issue_comments" ADD COLUMN "native_tool_generated" boolean DEFAULT false NOT NULL;--> statement-breakpoint
-- Materialize the exclusion once so finding a final response does not scan
-- every earlier progress/file-preparation comment in a long active run.
UPDATE issue_comments c SET native_tool_generated = true
FROM native_tool_receipt_references ref
JOIN native_tool_receipts receipt ON receipt.company_id = ref.company_id
AND receipt.run_id = ref.run_id AND receipt.key_sha256 = ref.key_sha256
WHERE ref.kind = 'generated-comment' AND ref.target = c.id::text
AND c.company_id = ref.company_id AND c.created_by_run_id = ref.run_id
AND c.issue_id = receipt.issue_id;
--> statement-breakpoint
-- paperclip:migration-safety-ignore large-create-index-not-concurrently: The generated-comment backfill and response index must publish together under the transactional migrator; CONCURRENTLY cannot run inside it. Schedule a maintenance window for this one-time history-sized backfill/index build before admitting normalized receipt writers.
CREATE INDEX "issue_comments_native_run_response_idx" ON "issue_comments" USING btree ("company_id","created_by_run_id","issue_id","created_at" DESC NULLS LAST,"id" DESC NULLS LAST) WHERE NOT "issue_comments"."native_tool_generated";
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+119
View File
@@ -1982,6 +1982,125 @@
"when": 1790439010225,
"tag": "0284_petite_genesis",
"breakpoints": true
},
{
"idx": 285,
"version": "7",
"when": 1790613030070,
"tag": "0285_flimsy_eternals",
"breakpoints": true
},
{
"idx": 286,
"version": "7",
"when": 1790624221631,
"tag": "0286_wet_vulcan",
"breakpoints": true
},
{
"idx": 287,
"version": "7",
"when": 1790636321055,
"tag": "0287_nebulous_lockjaw",
"breakpoints": true
},
{
"idx": 288,
"version": "7",
"when": 1790640779711,
"tag": "0288_brown_alex_power",
"breakpoints": true
},
{
"idx": 289,
"version": "7",
"when": 1790644151546,
"tag": "0289_fresh_firebird",
"breakpoints": true
},
{
"idx": 290,
"version": "7",
"when": 1790645703411,
"tag": "0290_broken_warbound",
"breakpoints": true
},
{
"idx": 291,
"version": "7",
"when": 1790676063944,
"tag": "0291_gray_turbo",
"breakpoints": true
},
{
"idx": 292,
"version": "7",
"when": 1790676840494,
"tag": "0292_rainy_leader",
"breakpoints": true
},
{
"idx": 293,
"version": "7",
"when": 1790679196545,
"tag": "0293_dazzling_the_hood",
"breakpoints": true
},
{
"idx": 294,
"version": "7",
"when": 1790683230994,
"tag": "0294_bright_king_bedlam",
"breakpoints": true
},
{
"idx": 295,
"version": "7",
"when": 1790687363825,
"tag": "0295_cooing_zodiak",
"breakpoints": true
},
{
"idx": 296,
"version": "7",
"when": 1790689608209,
"tag": "0296_unusual_firestar",
"breakpoints": true
},
{
"idx": 297,
"version": "7",
"when": 1790690144197,
"tag": "0297_medical_warbound",
"breakpoints": true
},
{
"idx": 298,
"version": "7",
"when": 1790690922300,
"tag": "0298_brown_northstar",
"breakpoints": true
},
{
"idx": 299,
"version": "7",
"when": 1790691821039,
"tag": "0299_flaky_justin_hammer",
"breakpoints": true
},
{
"idx": 300,
"version": "7",
"when": 1790703468269,
"tag": "0300_mushy_sunfire",
"breakpoints": true
},
{
"idx": 301,
"version": "7",
"when": 1790703770221,
"tag": "0301_petite_photon",
"breakpoints": true
}
]
}
@@ -30,8 +30,10 @@ export const agentTaskSessions = pgTable(
goalStatus: text("goal_status"),
goalDesiredState: text("goal_desired_state"),
goalSourceId: text("goal_source_id"),
goalSourceEpoch: text("goal_source_epoch"),
goalSourceCursor: bigint("goal_source_cursor", { mode: "number" }),
goalRevision: integer("goal_revision").notNull().default(0),
goalRevisionToken: text("goal_revision_token"),
goalObservedAt: timestamp("goal_observed_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
@@ -0,0 +1,42 @@
import { bigint, foreignKey, pgTable, primaryKey, text, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { heartbeatRunEvents } from "./heartbeat_run_events.js";
import { eventIdentity } from "../event-identity.js";
/** Closed public log namespaces are immutable. No lifetime epoch counter. */
export const heartbeatRunEventEpochs = pgTable("heartbeat_run_event_epochs", {
companyId: uuid("company_id").notNull(),
runId: uuid("run_id").notNull(),
epoch: text("epoch").notNull(),
nextEpoch: uuid("next_epoch").notNull(),
finalSeq: bigint("final_seq", { mode: "number" }).notNull(),
}, table => ({
pk: primaryKey({ columns: [table.runId, table.epoch] }),
successorUq: uniqueIndex("heartbeat_run_event_epochs_successor_uq").on(table.runId, table.nextEpoch),
runOwnerFk: foreignKey({ columns: [table.companyId, table.runId], foreignColumns: [heartbeatRuns.companyId, heartbeatRuns.id] }).onDelete("cascade"),
}));
/** Indexed newest-event anchors for semantic lanes. A lane is a fixed kind or
* a digest of an exact request/type; it never contains an entire transcript. */
export const heartbeatRunEventHeads = pgTable("heartbeat_run_event_heads", {
companyId: uuid("company_id").notNull(),
runId: uuid("run_id").notNull(),
lane: text("lane").notNull(),
eventId: eventIdentity("event_id").notNull().references(() => heartbeatRunEvents.id, { onDelete: "cascade" }),
}, table => ({
pk: primaryKey({ columns: [table.runId, table.lane] }),
runOwnerFk: foreignKey({ columns: [table.companyId, table.runId], foreignColumns: [heartbeatRuns.companyId, heartbeatRuns.id] }).onDelete("cascade"),
}));
/** Immutable predecessor links let recent/filtered views fetch a bounded
* window without sorting epoch UUIDs or searching old output for a match. */
export const heartbeatRunEventLinks = pgTable("heartbeat_run_event_links", {
companyId: uuid("company_id").notNull(),
runId: uuid("run_id").notNull(),
lane: text("lane").notNull(),
eventId: eventIdentity("event_id").notNull().references(() => heartbeatRunEvents.id, { onDelete: "cascade" }),
previousId: eventIdentity("previous_id"),
}, table => ({
pk: primaryKey({ columns: [table.runId, table.lane, table.eventId] }),
runOwnerFk: foreignKey({ columns: [table.companyId, table.runId], foreignColumns: [heartbeatRuns.companyId, heartbeatRuns.id] }).onDelete("cascade"),
}));
@@ -6,22 +6,23 @@ import {
timestamp,
integer,
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";
import { eventIdentity } from "../event-identity.js";
import { runEventPayload } from "../run-event-payload.js";
export const heartbeatRunEvents = pgTable(
"heartbeat_run_events",
{
id: bigserial("id", { mode: "number" }).primaryKey(),
id: eventIdentity("id").primaryKey().default(sql`gen_random_uuid()::text`),
companyId: uuid("company_id").notNull().references(() => companies.id),
runId: uuid("run_id").notNull().references(() => heartbeatRuns.id),
agentId: uuid("agent_id").notNull().references(() => agents.id),
eventEpoch: text("event_epoch").notNull().default(""),
seq: bigint("seq", { mode: "number" }).notNull(),
eventType: text("event_type").notNull(),
stream: text("stream"),
@@ -31,18 +32,19 @@ export const heartbeatRunEvents = pgTable(
payload: runEventPayload("payload"),
sourceInstanceId: text("source_instance_id"),
sourceEventId: text("source_event_id"),
sourceEpoch: text("source_epoch").notNull().default(""),
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) => ({
runSeqUq: uniqueIndex("heartbeat_run_events_run_seq_uq").on(table.runId, table.seq),
runSeqUq: uniqueIndex("heartbeat_run_events_run_seq_uq").on(table.runId, table.eventEpoch, 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)
.on(table.runId, table.sourceInstanceId, table.sourceEpoch, 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),
+3 -1
View File
@@ -53,6 +53,7 @@ export const heartbeatRuns = pgTable(
driverVersion: text("driver_version"),
completionContractId: uuid("completion_contract_id"),
completionContractSha256: text("completion_contract_sha256"),
eventEpoch: text("event_epoch").notNull().default(""),
nextEventSeq: bigint("next_event_seq", { mode: "number" }).notNull().default(1),
nativePhase: text("native_phase"),
nativePhaseUpdatedAt: timestamp("native_phase_updated_at", { withTimezone: true }),
@@ -75,7 +76,7 @@ export const heartbeatRuns = pgTable(
processGroupId: integer("process_group_id"),
processStartedAt: timestamp("process_started_at", { withTimezone: true }),
lastOutputAt: timestamp("last_output_at", { withTimezone: true }),
lastOutputSeq: integer("last_output_seq").notNull().default(0),
lastOutputSeq: bigint("last_output_seq", { mode: "number" }).notNull().default(0),
lastOutputStream: text("last_output_stream"),
lastOutputBytes: bigint("last_output_bytes", { mode: "number" }),
retryOfRunId: uuid("retry_of_run_id").references((): AnyPgColumn => heartbeatRuns.id, {
@@ -105,6 +106,7 @@ export const heartbeatRuns = pgTable(
nativeReplacementPredecessorUq: uniqueIndex("heartbeat_runs_native_replacement_predecessor_uq")
.on(table.companyId, table.retryOfRunId)
.where(sql`${table.scheduledRetryReason} = 'native_safe_replacement'`),
companyRunUq: unique("heartbeat_runs_company_id_run_id_uq").on(table.companyId, table.id),
companyNativeIssueRunUq: unique("heartbeat_runs_company_native_issue_id_uq").on(
table.companyId,
table.nativeIssueId,
+7
View File
@@ -210,3 +210,10 @@ export { aiConnectionDefaults } from "./ai_connection_defaults.js";
export { aiProviderDefaults } from "./ai_provider_defaults.js";
export * from "./email.js";
export { announcementDismissals, announcementPublications } from "./announcement_dismissals.js";
export { nativeSessionAuthorities, nativeAuthorityRecords, nativeAuthorityWork, nativeSourceCursors, nativeSourceEpochs } from "./native_session_authorities.js";
export { nativeOutputBodyChunks } from "./native_output_body_chunks.js";
export { nativeRunProcessEvidence } from "./native_run_process_evidence.js";
export { heartbeatRunEventEpochs, heartbeatRunEventHeads, heartbeatRunEventLinks } from "./heartbeat_run_event_history.js";
export { nativeToolReceipts, nativeToolReceiptReferences } from "./native_tool_receipts.js";
+7 -1
View File
@@ -5,7 +5,8 @@ import type {
IssueCommentPresentation,
SourceTrustMetadata,
} from "@paperclipai/shared";
import { pgTable, uuid, text, timestamp, index, jsonb, unique, integer } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { pgTable, uuid, text, timestamp, index, jsonb, unique, integer, boolean } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
import { agents } from "./agents.js";
@@ -23,6 +24,8 @@ export const issueComments = pgTable(
onBehalfOfUserId: text("on_behalf_of_user_id").references(() => authUsers.id, { onDelete: "set null" }),
authorType: text("author_type").$type<IssueCommentAuthorType>(),
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
// Server-derived with the business receipt, never supplied by a comment author.
nativeToolGenerated: boolean("native_tool_generated").notNull().default(false),
// Persisted result of best-effort agent-attribution derivation for comments
// authored by a non-human sentinel (e.g. `local-board`). Populated once by a
// backfill migration and lazily on read so the load path stops re-scanning
@@ -49,6 +52,9 @@ export const issueComments = pgTable(
companyIdUq: unique("issue_comments_company_id_uq").on(table.companyId, table.id),
issueIdx: index("issue_comments_issue_idx").on(table.issueId),
companyIdx: index("issue_comments_company_idx").on(table.companyId),
nativeRunResponseIdx: index("issue_comments_native_run_response_idx").on(
table.companyId, table.createdByRunId, table.issueId, table.createdAt.desc(), table.id.desc(),
).where(sql`NOT ${table.nativeToolGenerated}`),
companyIssueCreatedAtIdx: index("issue_comments_company_issue_created_at_idx").on(
table.companyId,
table.issueId,
@@ -0,0 +1,25 @@
import { bigint, check, foreignKey, index, pgTable, primaryKey, text, uuid } from "drizzle-orm/pg-core";
import { eventIdentity } from "../event-identity.js";
import { sql } from "drizzle-orm";
import { heartbeatRunEvents } from "./heartbeat_run_events.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
/** Tiny locator rows written atomically with native events. A new table avoids
* scanning/locking the entire historical run log during feature activation. */
export const nativeOutputBodyChunks = pgTable("native_output_body_chunks", {
companyId: uuid("company_id").notNull(),
runId: uuid("run_id").notNull(),
bodyId: text("body_id").notNull(),
chunkOffset: bigint("chunk_offset", { mode: "number" }).notNull(),
chunkSha256: text("chunk_sha256").notNull(),
sourceSeq: bigint("source_seq", { mode: "number" }).notNull(),
eventId: eventIdentity("event_id").notNull().references(() => heartbeatRunEvents.id, { onDelete: "cascade" }),
}, table => ({
pk: primaryKey({ columns: [table.companyId, table.runId, table.bodyId, table.chunkOffset] }),
eventIdx: index("native_output_body_chunks_event_idx").on(table.eventId),
runOwnerFk: foreignKey({ columns: [table.companyId, table.runId], foreignColumns: [heartbeatRuns.companyId, heartbeatRuns.id], name: "native_output_body_chunks_run_owner_fk" }).onDelete("cascade"),
bodyIdValid: check("native_output_body_chunks_body_id_valid", sql`${table.bodyId} ~ '^[a-f0-9]{64}$'`),
chunkOffsetBound: check("native_output_body_chunks_offset_bound", sql`${table.chunkOffset} >= 0 and ${table.chunkOffset} < 4194304`),
chunkHashValid: check("native_output_body_chunks_hash_valid", sql`${table.chunkSha256} ~ '^[a-f0-9]{64}$'`),
sequencePositive: check("native_output_body_chunks_sequence_positive", sql`${table.sourceSeq} > 0`),
}));
@@ -0,0 +1,18 @@
import { bigint, check, foreignKey, pgTable, primaryKey, text, integer, uuid } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { heartbeatRuns } from "./heartbeat_runs.js";
/** Current server-observed process lifecycle. Historical output is never
* scanned on ordinary stop/recovery checks, including when no process exists. */
export const nativeRunProcessEvidence = pgTable("native_run_process_evidence", {
companyId: uuid("company_id").notNull(),
runId: uuid("run_id").notNull(),
seq: bigint("seq", { mode: "number" }).notNull(),
eventType: text("event_type"),
processPid: integer("process_pid"),
processGroupId: integer("process_group_id"),
}, table => ({
pk: primaryKey({ columns: [table.companyId, table.runId] }),
runOwnerFk: foreignKey({ columns: [table.companyId, table.runId], foreignColumns: [heartbeatRuns.companyId, heartbeatRuns.id], name: "native_run_process_evidence_owner_fk" }).onDelete("cascade"),
eventValid: check("native_run_process_evidence_event_valid", sql`(${table.seq} = 0 and ${table.eventType} is null) or (${table.seq} > 0 and ${table.eventType} is not null and ${table.eventType} in ('native.process_start_requested', 'native.process_identity_recorded', 'native.local_process_stopped'))`),
}));
@@ -0,0 +1,106 @@
import { sql } from "drizzle-orm";
import { bigint, check, foreignKey, jsonb, pgTable, primaryKey, text, timestamp, unique, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
/** Current authority only; settled receipts and history never accumulate here. */
export const nativeSessionAuthorities = pgTable("native_session_authorities", {
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
issueId: uuid("issue_id").notNull(),
normalizedSessionId: text("normalized_session_id").notNull(),
runId: uuid("run_id").notNull(),
successorRunId: uuid("successor_run_id"),
binding: text("binding").notNull(),
generation: text("generation").notNull(),
committedFrom: text("committed_from"),
state: text("state").notNull(),
stateSha256: text("state_sha256").notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
pk: primaryKey({ columns: [table.companyId, table.normalizedSessionId, table.runId] }),
ownerUq: unique("native_session_authorities_owner_uq").on(table.companyId, table.issueId, table.normalizedSessionId, table.runId),
issueOwnerFk: foreignKey({ columns: [table.companyId, table.issueId], foreignColumns: [issues.companyId, issues.id], name: "native_session_authorities_issue_owner_fk" }).onDelete("cascade"),
runOwnerFk: foreignKey({ columns: [table.companyId, table.issueId, table.runId], foreignColumns: [heartbeatRuns.companyId, heartbeatRuns.nativeIssueId, heartbeatRuns.id], name: "native_session_authorities_run_owner_fk" }).onDelete("cascade"),
stateBound: check("native_session_authorities_state_bound", sql`octet_length(${table.state}) <= 16777216`),
generationPositive: check("native_session_authorities_generation_positive", sql`${table.generation} ~ '^(r:[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}|[1-9][0-9]{0,18})$'`),
predecessorValid: check("native_session_authorities_predecessor_valid", sql`(${table.generation} NOT LIKE 'r:%' OR ${table.committedFrom} IS NOT NULL) AND (${table.committedFrom} IS NULL OR (${table.committedFrom} <> ${table.generation} AND ${table.committedFrom} ~ '^(r:[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}|0|[1-9][0-9]{0,18})$'))`),
}));
/** Immutable replay receipts, keyed by authenticated epoch. Pagination and
* ancient replay use indexes; neither path reconstructs the current snapshot. */
export const nativeAuthorityRecords = pgTable("native_authority_records", {
companyId: uuid("company_id").notNull(),
issueId: uuid("issue_id").notNull(),
normalizedSessionId: text("normalized_session_id").notNull(),
runId: uuid("run_id").notNull(),
kind: text("kind").notNull(),
recordId: text("record_id").notNull(),
sequence: bigint("sequence", { mode: "bigint" }).notNull(),
sequenceEpoch: text("sequence_epoch").notNull().default(""),
body: text("body").notNull(),
bodySha256: text("body_sha256").notNull(),
bodyEncoding: text("body_encoding").notNull().default("json"),
bodyBytes: bigint("body_bytes", { mode: "number" }),
}, (table) => ({
pk: primaryKey({ columns: [table.companyId, table.normalizedSessionId, table.runId, table.kind, table.recordId] }),
sessionEffectUq: uniqueIndex("native_authority_records_session_effect_uq").on(table.companyId, table.normalizedSessionId, table.recordId).where(sql`${table.kind} = 'effect'`),
sequenceUq: uniqueIndex("native_authority_records_sequence_uq").on(table.companyId, table.normalizedSessionId, table.runId, table.kind, table.sequenceEpoch, table.sequence).where(sql`${table.kind} <> 'effect'`),
authorityOwnerFk: foreignKey({ columns: [table.companyId, table.issueId, table.normalizedSessionId, table.runId], foreignColumns: [nativeSessionAuthorities.companyId, nativeSessionAuthorities.issueId, nativeSessionAuthorities.normalizedSessionId, nativeSessionAuthorities.runId], name: "native_authority_records_authority_owner_fk" }).onDelete("cascade"),
runOwnerFk: foreignKey({ columns: [table.companyId, table.issueId, table.runId], foreignColumns: [heartbeatRuns.companyId, heartbeatRuns.nativeIssueId, heartbeatRuns.id], name: "native_authority_records_run_owner_fk" }).onDelete("cascade"),
bodyBound: check("native_authority_records_body_bound", sql`octet_length(${table.body}) <= 1048576`),
bodyEncodingValid: check("native_authority_records_body_encoding_valid", sql`(${table.bodyEncoding} = 'json' AND ${table.bodyBytes} IS NULL) OR (${table.bodyEncoding} = 'object.v1' AND ${table.bodyBytes} IS NOT NULL AND ${table.bodyBytes} BETWEEN 1 AND 1048576)`),
kindValid: check("native_authority_records_kind_valid", sql`${table.kind} in ('command', 'event', 'effect')`),
sequenceEpochValid: check("native_authority_records_sequence_epoch_valid", sql`${table.sequenceEpoch} = '' OR (${table.kind} IN ('command', 'event') AND ${table.sequenceEpoch} ~ '^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$')`),
sequencePositive: check("native_authority_records_sequence_positive", sql`${table.sequence} > 0 OR (${table.kind} = 'effect' AND ${table.sequence} = 0)`),
}));
/** Unresolved owners are paged current work, not a growing checkpoint array.
* The originating run stays attached when a later epoch retires the owner. */
export const nativeAuthorityWork = pgTable("native_authority_work", {
companyId: uuid("company_id").notNull(),
issueId: uuid("issue_id").notNull(),
normalizedSessionId: text("normalized_session_id").notNull(),
runId: uuid("run_id").notNull(),
collection: text("collection").notNull(),
workId: text("work_id").notNull(),
body: text("body").notNull(),
bodySha256: text("body_sha256").notNull(),
}, table => ({
pk: primaryKey({ columns: [table.companyId, table.normalizedSessionId, table.collection, table.workId] }),
authorityOwnerFk: foreignKey({ columns: [table.companyId, table.issueId, table.normalizedSessionId, table.runId], foreignColumns: [nativeSessionAuthorities.companyId, nativeSessionAuthorities.issueId, nativeSessionAuthorities.normalizedSessionId, nativeSessionAuthorities.runId], name: "native_authority_work_authority_owner_fk" }).onDelete("cascade"),
bodyBound: check("native_authority_work_body_bound", sql`octet_length(${table.body}) <= 16384`),
collectionValid: check("native_authority_work_collection_valid", sql`${table.collection} = 'process-owner'`),
}));
/** Cumulative source cursor, maintained with event insertion; independent of history size. */
export const nativeSourceCursors = pgTable("native_source_cursors", {
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
runId: uuid("run_id").notNull(),
sourceInstanceId: text("source_instance_id").notNull(),
sourceEpoch: text("source_epoch").notNull().default(""),
cursor: bigint("cursor", { mode: "number" }).notNull().default(0),
}, (table) => ({
pk: primaryKey({ columns: [table.runId, table.sourceInstanceId] }),
runOwnerFk: foreignKey({ columns: [table.companyId, table.runId], foreignColumns: [heartbeatRuns.companyId, heartbeatRuns.id], name: "native_source_cursors_run_owner_fk" }).onDelete("cascade"),
cursorPositive: check("native_source_cursors_cursor_positive", sql`${table.cursor} >= 0`),
}));
/** One immutable close receipt per normalized source namespace. The current
* head stays in native_source_cursors; opening it never scans these receipts. */
export const nativeSourceEpochs = pgTable("native_source_epochs", {
companyId: uuid("company_id").notNull(),
runId: uuid("run_id").notNull(),
sourceInstanceId: text("source_instance_id").notNull(),
fromEpoch: text("from_epoch").notNull(),
nextEpoch: uuid("next_epoch").notNull(),
transitionId: uuid("transition_id").notNull(),
finalOrdinal: bigint("final_ordinal", { mode: "number" }).notNull(),
transition: jsonb("transition").$type<Record<string, unknown>>().notNull(),
}, table => ({
pk: primaryKey({ columns: [table.runId, table.sourceInstanceId, table.fromEpoch] }),
successorUq: uniqueIndex("native_source_epochs_successor_uq").on(table.runId, table.sourceInstanceId, table.nextEpoch),
transitionUq: uniqueIndex("native_source_epochs_transition_uq").on(table.runId, table.sourceInstanceId, table.transitionId),
runOwnerFk: foreignKey({ columns: [table.companyId, table.runId], foreignColumns: [heartbeatRuns.companyId, heartbeatRuns.id], name: "native_source_epochs_run_owner_fk" }).onDelete("cascade"),
ordinalBound: check("native_source_epochs_ordinal_bound", sql`${table.finalOrdinal} > 0 and ${table.finalOrdinal} <= 9007199254740991`),
}));
@@ -0,0 +1,32 @@
import { foreignKey, jsonb, pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { heartbeatRuns } from "./heartbeat_runs.js";
/** Business-effect replay authority, independent of protocol delivery receipts.
* The digest is an index key only; readers verify the original key exactly. */
export const nativeToolReceipts = pgTable("native_tool_receipts", {
companyId: uuid("company_id").notNull(),
issueId: uuid("issue_id").notNull(),
runId: uuid("run_id").notNull(),
keySha256: text("key_sha256").notNull(),
idempotencyKey: text("idempotency_key").notNull(),
receipt: jsonb("receipt").$type<unknown>().notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, table => ({
pk: primaryKey({ name: "native_tool_receipts_pk", columns: [table.companyId, table.runId, table.keySha256] }),
runOwnerFk: foreignKey({ name: "native_tool_receipts_run_owner_fk", columns: [table.companyId, table.issueId, table.runId],
foreignColumns: [heartbeatRuns.companyId, heartbeatRuns.nativeIssueId, heartbeatRuns.id] }).onDelete("cascade"),
}));
/** Point lookups for publication proof, attachment reuse and generated comments.
* These references commit with their receipt and the business mutation. */
export const nativeToolReceiptReferences = pgTable("native_tool_receipt_references", {
companyId: uuid("company_id").notNull(),
runId: uuid("run_id").notNull(),
kind: text("kind").notNull(),
target: text("target").notNull(),
keySha256: text("key_sha256").notNull(),
}, table => ({
pk: primaryKey({ name: "native_tool_receipt_references_pk", columns: [table.companyId, table.runId, table.kind, table.target, table.keySha256] }),
receiptFk: foreignKey({ name: "native_tool_receipt_references_receipt_fk", columns: [table.companyId, table.runId, table.keySha256],
foreignColumns: [nativeToolReceipts.companyId, nativeToolReceipts.runId, nativeToolReceipts.keySha256] }).onDelete("cascade"),
}));
+5
View File
@@ -6,12 +6,15 @@ export {
runnerGoalStatusSchema,
runnerGoalActionSchema,
runnerGoalPendingActionSchema,
runnerGoalRevisionTokenSchema,
runnerGoalRevisionSchema,
runnerGoalActionRequestSchema,
type RunnerGoalAvailability,
type RunnerGoalCapabilityAction,
type RunnerGoalStatus,
type RunnerGoalAction,
type RunnerGoalPendingAction,
type RunnerGoalRevision,
type RunnerGoalCapability,
type RunnerGoalSnapshot,
type RunnerGoalProjection,
@@ -1261,6 +1264,8 @@ export type {
GitWorktreeInProgressOperation,
HeartbeatRun,
HeartbeatRunEvent,
HeartbeatRunLogPage,
RunOutputBodyReference,
HeartbeatRunStatusPhase,
ProviderTraceDebugRequest,
ProviderTraceDirection,
+9 -2
View File
@@ -44,6 +44,13 @@ export const runnerGoalPendingActionSchema = z.enum([
]);
export type RunnerGoalPendingAction = z.infer<typeof runnerGoalPendingActionSchema>;
export const runnerGoalRevisionTokenSchema = z.string().regex(/^r:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
export const runnerGoalRevisionSchema = z.union([
z.number().int().safe().nonnegative(),
runnerGoalRevisionTokenSchema,
]);
export type RunnerGoalRevision = z.infer<typeof runnerGoalRevisionSchema>;
export interface RunnerGoalCapability {
availability: RunnerGoalAvailability;
verified?: boolean;
@@ -81,7 +88,7 @@ export interface RunnerGoalProjection {
workingNow: boolean;
activeRunId: string | null;
pendingAction: RunnerGoalPendingAction | null;
revision: number;
revision: RunnerGoalRevision;
observedAt: string | null;
}
@@ -95,7 +102,7 @@ export const runnerGoalActionRequestSchema = z
.object({
requestId: z.string().trim().min(1).max(160),
agentId: z.string().uuid(),
expectedRevision: z.number().int().nonnegative(),
expectedRevision: runnerGoalRevisionSchema,
action: runnerGoalActionSchema,
objective: objectiveSchema.optional(),
tokenBudget: z.number().int().positive().nullable().optional(),
+27 -1
View File
@@ -290,11 +290,16 @@ export interface ChatFailedRunRetryResponse {
}
export interface HeartbeatRunEvent {
id: number;
id: number | string;
companyId: string;
runId: string;
agentId: string;
seq: number;
eventEpoch?: string;
cursor?: string;
historyBefore?: boolean;
/** More rows follow even when the response reached its byte budget first. */
historyAfter?: boolean;
eventType: string;
stream: "system" | "stdout" | "stderr" | null;
level: "info" | "warn" | "error" | null;
@@ -304,6 +309,18 @@ export interface HeartbeatRunEvent {
createdAt: Date;
}
/** Page of the persisted run log. Exact cursors are decimal byte offsets;
* clients must retain them verbatim instead of counting rendered characters. */
export interface HeartbeatRunLogPage {
runId: string;
store: string;
logRef: string;
content: string;
nextOffset?: number;
cursor?: string;
hasMore?: boolean;
}
export interface AgentRuntimeState {
agentId: string;
companyId: string;
@@ -375,3 +392,12 @@ export interface InstanceSchedulerHeartbeatAgent {
schedulerActive: boolean;
lastHeartbeatAt: Date | null;
}
/** A single admitted provider output, scoped by its authenticated run URL. */
export interface RunOutputBodyReference {
schema: "paperclip.output.body.v1";
bodyId: string;
sha256: string;
byteLength: string;
mediaType: "text/plain; charset=utf-8";
}
+2
View File
@@ -887,6 +887,8 @@ export type {
GitWorktreeInProgressOperation,
HeartbeatRun,
HeartbeatRunEvent,
HeartbeatRunLogPage,
RunOutputBodyReference,
HeartbeatRunStatusPhase,
ProviderTraceDebugRequest,
ProviderTraceDirection,
+2
View File
@@ -952,6 +952,8 @@ export interface IssueComment {
/** Responsible user attribution. Legacy and plugin-provided comment values may omit it. */
onBehalfOfUserId?: string | null;
createdByRunId?: string | null;
/** Server-derived progress/file-preparation provenance; not an author input. */
nativeToolGenerated?: boolean;
derivedAuthorAgentId?: string | null;
derivedCreatedByRunId?: string | null;
derivedAuthorSource?: IssueCommentDerivedAuthorSource | null;