mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
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:
1 parent
e6bed1231a
commit
8ecb181d9c
44 files changed
+56887
-48246
No files matched your search
+119
-1
@@ -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.
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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";
|
||||
-47808
File diff suppressed because it is too large.
Load diff
+958
-3
File diff suppressed because it is too large.
Load diff
+971
-3
File diff suppressed because it is too large.
Load diff
+1751
-5
File diff suppressed because it is too large.
Load diff
+1919
-5
File diff suppressed because it is too large.
Load diff
+49883
File diff suppressed because it is too large.
Load diff
+119
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
@@ -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"),
|
||||
}));
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -887,6 +887,8 @@ export type {
|
||||
GitWorktreeInProgressOperation,
|
||||
HeartbeatRun,
|
||||
HeartbeatRunEvent,
|
||||
HeartbeatRunLogPage,
|
||||
RunOutputBodyReference,
|
||||
HeartbeatRunStatusPhase,
|
||||
ProviderTraceDebugRequest,
|
||||
ProviderTraceDirection,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user