Add agent task sessions table, session types, and programmatic DB backup
Add agent_task_sessions table for per-task session state keyed by (agent, adapter, taskKey). Add AgentTaskSession type, resetAgentSessionSchema validator, and sessionDisplayId/sessionParamsJson to AgentRuntimeState. Rework migration hash-resolution fallback ordering to prefer hash-based matching over timestamp-based journal matching. Move backup-db.sh logic into packages/db/src/backup.ts for programmatic use and simplify the shell script to call the TypeScript implementation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
338
packages/db/src/backup.ts
Normal file
338
packages/db/src/backup.ts
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
|
const PROJECT_ROOT = resolve(import.meta.dirname, "../../..");
|
||||||
|
const BACKUP_DIR = resolve(PROJECT_ROOT, "data/backups");
|
||||||
|
const CONFIG_FILE = resolve(PROJECT_ROOT, ".paperclip/config.json");
|
||||||
|
const MAX_AGE_DAYS = 30;
|
||||||
|
|
||||||
|
function loadPort(): number {
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(CONFIG_FILE, "utf8");
|
||||||
|
const config = JSON.parse(raw);
|
||||||
|
const port = config?.database?.embeddedPostgresPort;
|
||||||
|
if (typeof port === "number" && Number.isFinite(port)) return port;
|
||||||
|
} catch {}
|
||||||
|
return 54329;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestamp(): string {
|
||||||
|
const d = new Date();
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneOldBackups() {
|
||||||
|
if (!existsSync(BACKUP_DIR)) return;
|
||||||
|
const cutoff = Date.now() - MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
|
||||||
|
let pruned = 0;
|
||||||
|
for (const name of readdirSync(BACKUP_DIR)) {
|
||||||
|
if (!name.startsWith("paperclip-") || !name.endsWith(".sql")) continue;
|
||||||
|
const fullPath = resolve(BACKUP_DIR, name);
|
||||||
|
const stat = statSync(fullPath);
|
||||||
|
if (stat.mtimeMs < cutoff) {
|
||||||
|
unlinkSync(fullPath);
|
||||||
|
pruned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pruned > 0) console.log(`Pruned ${pruned} backup(s) older than ${MAX_AGE_DAYS} days.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const port = loadPort();
|
||||||
|
const connString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||||
|
|
||||||
|
console.log(`Connecting to embedded PostgreSQL on port ${port}...`);
|
||||||
|
|
||||||
|
const sql = postgres(connString, { max: 1, connect_timeout: 5 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify connection
|
||||||
|
await sql`SELECT 1`;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`Error: Cannot connect to embedded PostgreSQL on port ${port}.`);
|
||||||
|
console.error(" Make sure the server is running (pnpm dev).");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const emit = (line: string) => lines.push(line);
|
||||||
|
|
||||||
|
emit("-- Paperclip database backup");
|
||||||
|
emit(`-- Created: ${new Date().toISOString()}`);
|
||||||
|
emit(`-- Server port: ${port}`);
|
||||||
|
emit("");
|
||||||
|
emit("BEGIN;");
|
||||||
|
emit("");
|
||||||
|
|
||||||
|
// Get all enums
|
||||||
|
const enums = await sql<{ typname: string; labels: string[] }[]>`
|
||||||
|
SELECT 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 n.nspname = 'public'
|
||||||
|
GROUP BY t.typname
|
||||||
|
ORDER BY t.typname
|
||||||
|
`;
|
||||||
|
|
||||||
|
for (const e of enums) {
|
||||||
|
const labels = e.labels.map((l) => `'${l.replace(/'/g, "''")}'`).join(", ");
|
||||||
|
emit(`CREATE TYPE "public"."${e.typname}" AS ENUM (${labels});`);
|
||||||
|
}
|
||||||
|
if (enums.length > 0) emit("");
|
||||||
|
|
||||||
|
// Get tables in dependency order (referenced tables first)
|
||||||
|
const tables = await sql<{ tablename: string }[]>`
|
||||||
|
SELECT c.relname AS tablename
|
||||||
|
FROM pg_class c
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE n.nspname = 'public'
|
||||||
|
AND c.relkind = 'r'
|
||||||
|
AND c.relname != '__drizzle_migrations'
|
||||||
|
ORDER BY c.relname
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Get full CREATE TABLE DDL via column info
|
||||||
|
for (const { tablename } of tables) {
|
||||||
|
const columns = await sql<{
|
||||||
|
column_name: string;
|
||||||
|
data_type: 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_name, is_nullable, column_default,
|
||||||
|
character_maximum_length, numeric_precision, numeric_scale
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = ${tablename}
|
||||||
|
ORDER BY ordinal_position
|
||||||
|
`;
|
||||||
|
|
||||||
|
emit(`-- Table: ${tablename}`);
|
||||||
|
emit(`DROP TABLE IF EXISTS "${tablename}" CASCADE;`);
|
||||||
|
|
||||||
|
const colDefs: string[] = [];
|
||||||
|
for (const col of columns) {
|
||||||
|
let typeStr: string;
|
||||||
|
if (col.data_type === "USER-DEFINED") {
|
||||||
|
typeStr = `"${col.udt_name}"`;
|
||||||
|
} else if (col.data_type === "ARRAY") {
|
||||||
|
typeStr = `${col.udt_name.replace(/^_/, "")}[]`;
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = 'public' 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 "${tablename}" (`);
|
||||||
|
emit(colDefs.join(",\n"));
|
||||||
|
emit(");");
|
||||||
|
emit("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Foreign keys (after all tables created)
|
||||||
|
const fks = await sql<{
|
||||||
|
constraint_name: string;
|
||||||
|
source_table: string;
|
||||||
|
source_columns: string[];
|
||||||
|
target_table: string;
|
||||||
|
target_columns: string[];
|
||||||
|
update_rule: string;
|
||||||
|
delete_rule: string;
|
||||||
|
}[]>`
|
||||||
|
SELECT
|
||||||
|
c.conname AS constraint_name,
|
||||||
|
src.relname AS source_table,
|
||||||
|
array_agg(sa.attname ORDER BY array_position(c.conkey, sa.attnum)) AS source_columns,
|
||||||
|
tgt.relname AS target_table,
|
||||||
|
array_agg(ta.attname ORDER BY array_position(c.confkey, ta.attnum)) 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_class tgt ON tgt.oid = c.confrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = src.relnamespace
|
||||||
|
JOIN pg_attribute sa ON sa.attrelid = src.oid AND sa.attnum = ANY(c.conkey)
|
||||||
|
JOIN pg_attribute ta ON ta.attrelid = tgt.oid AND ta.attnum = ANY(c.confkey)
|
||||||
|
WHERE c.contype = 'f' AND n.nspname = 'public'
|
||||||
|
GROUP BY c.conname, src.relname, tgt.relname, c.confupdtype, c.confdeltype
|
||||||
|
ORDER BY src.relname, c.conname
|
||||||
|
`;
|
||||||
|
|
||||||
|
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(", ");
|
||||||
|
emit(
|
||||||
|
`ALTER TABLE "${fk.source_table}" ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES "${fk.target_table}" (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
emit("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unique constraints
|
||||||
|
const uniques = await sql<{
|
||||||
|
constraint_name: string;
|
||||||
|
tablename: string;
|
||||||
|
column_names: string[];
|
||||||
|
}[]>`
|
||||||
|
SELECT c.conname AS constraint_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 = 'public' AND c.contype = 'u'
|
||||||
|
GROUP BY c.conname, t.relname
|
||||||
|
ORDER BY t.relname, c.conname
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (uniques.length > 0) {
|
||||||
|
emit("-- Unique constraints");
|
||||||
|
for (const u of uniques) {
|
||||||
|
const cols = u.column_names.map((c) => `"${c}"`).join(", ");
|
||||||
|
emit(`ALTER TABLE "${u.tablename}" ADD CONSTRAINT "${u.constraint_name}" UNIQUE (${cols});`);
|
||||||
|
}
|
||||||
|
emit("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indexes (non-primary, non-unique-constraint)
|
||||||
|
const indexes = await sql<{ indexdef: string }[]>`
|
||||||
|
SELECT indexdef
|
||||||
|
FROM pg_indexes
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
AND indexname NOT IN (
|
||||||
|
SELECT conname FROM pg_constraint
|
||||||
|
WHERE connamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')
|
||||||
|
)
|
||||||
|
ORDER BY tablename, indexname
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (indexes.length > 0) {
|
||||||
|
emit("-- Indexes");
|
||||||
|
for (const idx of indexes) {
|
||||||
|
emit(`${idx.indexdef};`);
|
||||||
|
}
|
||||||
|
emit("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dump data for each table
|
||||||
|
for (const { tablename } of tables) {
|
||||||
|
const count = await sql<{ n: number }[]>`
|
||||||
|
SELECT count(*)::int AS n FROM ${sql(tablename)}
|
||||||
|
`;
|
||||||
|
if ((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 = 'public' AND table_name = ${tablename}
|
||||||
|
ORDER BY ordinal_position
|
||||||
|
`;
|
||||||
|
const colNames = cols.map((c) => `"${c.column_name}"`).join(", ");
|
||||||
|
|
||||||
|
emit(`-- Data for: ${tablename} (${count[0]!.n} rows)`);
|
||||||
|
|
||||||
|
const rows = await sql`SELECT * FROM ${sql(tablename)}`.values();
|
||||||
|
for (const row of rows) {
|
||||||
|
const values = row.map((val: any) => {
|
||||||
|
if (val === null || val === undefined) return "NULL";
|
||||||
|
if (typeof val === "boolean") return val ? "true" : "false";
|
||||||
|
if (typeof val === "number") return String(val);
|
||||||
|
if (val instanceof Date) return `'${val.toISOString()}'`;
|
||||||
|
if (typeof val === "object") return `'${JSON.stringify(val).replace(/'/g, "''")}'`;
|
||||||
|
return `'${String(val).replace(/'/g, "''")}'`;
|
||||||
|
});
|
||||||
|
emit(`INSERT INTO "${tablename}" (${colNames}) VALUES (${values.join(", ")});`);
|
||||||
|
}
|
||||||
|
emit("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sequence values
|
||||||
|
const sequences = await sql<{ sequence_name: string }[]>`
|
||||||
|
SELECT sequence_name
|
||||||
|
FROM information_schema.sequences
|
||||||
|
WHERE sequence_schema = 'public'
|
||||||
|
ORDER BY sequence_name
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (sequences.length > 0) {
|
||||||
|
emit("-- Sequence values");
|
||||||
|
for (const seq of sequences) {
|
||||||
|
const val = await sql<{ last_value: string }[]>`
|
||||||
|
SELECT last_value::text FROM ${sql(seq.sequence_name)}
|
||||||
|
`;
|
||||||
|
if (val[0]) {
|
||||||
|
emit(`SELECT setval('"${seq.sequence_name}"', ${val[0].last_value});`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit("");
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("COMMIT;");
|
||||||
|
emit("");
|
||||||
|
|
||||||
|
// Write the backup file
|
||||||
|
mkdirSync(BACKUP_DIR, { recursive: true });
|
||||||
|
const backupFile = resolve(BACKUP_DIR, `paperclip-${timestamp()}.sql`);
|
||||||
|
await writeFile(backupFile, lines.join("\n"), "utf8");
|
||||||
|
|
||||||
|
const sizeBytes = statSync(backupFile).size;
|
||||||
|
const sizeStr =
|
||||||
|
sizeBytes < 1024
|
||||||
|
? `${sizeBytes}B`
|
||||||
|
: sizeBytes < 1024 * 1024
|
||||||
|
? `${(sizeBytes / 1024).toFixed(1)}K`
|
||||||
|
: `${(sizeBytes / (1024 * 1024)).toFixed(1)}M`;
|
||||||
|
|
||||||
|
console.log(`Backup saved: ${backupFile} (${sizeStr})`);
|
||||||
|
|
||||||
|
pruneOldBackups();
|
||||||
|
} finally {
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -243,23 +243,6 @@ async function loadAppliedMigrations(
|
|||||||
return rows.map((row) => row.name).filter((name): name is string => Boolean(name));
|
return rows.map((row) => row.name).filter((name): name is string => Boolean(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (columnNames.has("hash") && columnNames.has("created_at")) {
|
|
||||||
const journalEntries = await listJournalMigrationEntries();
|
|
||||||
if (journalEntries.length > 0) {
|
|
||||||
const lastDbRows = await sql.unsafe<{ created_at: string | number | null }[]>(
|
|
||||||
`SELECT created_at FROM ${qualifiedTable} ORDER BY created_at DESC LIMIT 1`,
|
|
||||||
);
|
|
||||||
const lastCreatedAt = Number(lastDbRows[0]?.created_at ?? -1);
|
|
||||||
if (Number.isFinite(lastCreatedAt) && lastCreatedAt >= 0) {
|
|
||||||
return journalEntries
|
|
||||||
.filter((entry) => availableMigrations.includes(entry.fileName))
|
|
||||||
.filter((entry) => entry.folderMillis <= lastCreatedAt)
|
|
||||||
.map((entry) => entry.fileName);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (columnNames.has("hash")) {
|
if (columnNames.has("hash")) {
|
||||||
const rows = await sql.unsafe<{ hash: string }[]>(`SELECT hash FROM ${qualifiedTable} ORDER BY id`);
|
const rows = await sql.unsafe<{ hash: string }[]>(`SELECT hash FROM ${qualifiedTable} ORDER BY id`);
|
||||||
const hashesToMigrationFiles = await mapHashesToMigrationFiles(availableMigrations);
|
const hashesToMigrationFiles = await mapHashesToMigrationFiles(availableMigrations);
|
||||||
@@ -267,7 +250,31 @@ async function loadAppliedMigrations(
|
|||||||
.map((row) => hashesToMigrationFiles.get(row.hash))
|
.map((row) => hashesToMigrationFiles.get(row.hash))
|
||||||
.filter((name): name is string => Boolean(name));
|
.filter((name): name is string => Boolean(name));
|
||||||
|
|
||||||
if (appliedFromHashes.length > 0) return appliedFromHashes;
|
if (appliedFromHashes.length > 0) {
|
||||||
|
// Best-effort: when all hashes resolve, this is authoritative.
|
||||||
|
if (appliedFromHashes.length === rows.length) return appliedFromHashes;
|
||||||
|
|
||||||
|
// Partial hash resolution can happen when files have changed; return what we can trust.
|
||||||
|
return appliedFromHashes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback only when hashes are unavailable/unresolved.
|
||||||
|
if (columnNames.has("created_at")) {
|
||||||
|
const journalEntries = await listJournalMigrationEntries();
|
||||||
|
if (journalEntries.length > 0) {
|
||||||
|
const lastDbRows = await sql.unsafe<{ created_at: string | number | null }[]>(
|
||||||
|
`SELECT created_at FROM ${qualifiedTable} ORDER BY created_at DESC LIMIT 1`,
|
||||||
|
);
|
||||||
|
const lastCreatedAt = Number(lastDbRows[0]?.created_at ?? -1);
|
||||||
|
if (Number.isFinite(lastCreatedAt) && lastCreatedAt >= 0) {
|
||||||
|
return journalEntries
|
||||||
|
.filter((entry) => availableMigrations.includes(entry.fileName))
|
||||||
|
.filter((entry) => entry.folderMillis <= lastCreatedAt)
|
||||||
|
.map((entry) => entry.fileName)
|
||||||
|
.slice(0, rows.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await sql.unsafe<{ id: number }[]>(`SELECT id FROM ${qualifiedTable} ORDER BY id`);
|
const rows = await sql.unsafe<{ id: number }[]>(`SELECT id FROM ${qualifiedTable} ORDER BY id`);
|
||||||
|
|||||||
20
packages/db/src/migrations/0007_new_quentin_quire.sql
Normal file
20
packages/db/src/migrations/0007_new_quentin_quire.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE "agent_task_sessions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"agent_id" uuid NOT NULL,
|
||||||
|
"adapter_type" text NOT NULL,
|
||||||
|
"task_key" text NOT NULL,
|
||||||
|
"session_params_json" jsonb,
|
||||||
|
"session_display_id" text,
|
||||||
|
"last_run_id" uuid,
|
||||||
|
"last_error" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "agent_task_sessions" ADD CONSTRAINT "agent_task_sessions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "agent_task_sessions" ADD CONSTRAINT "agent_task_sessions_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "agent_task_sessions" ADD CONSTRAINT "agent_task_sessions_last_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("last_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "agent_task_sessions_company_agent_adapter_task_uniq" ON "agent_task_sessions" USING btree ("company_id","agent_id","adapter_type","task_key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "agent_task_sessions_company_agent_updated_idx" ON "agent_task_sessions" USING btree ("company_id","agent_id","updated_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "agent_task_sessions_company_task_updated_idx" ON "agent_task_sessions" USING btree ("company_id","task_key","updated_at");
|
||||||
3258
packages/db/src/migrations/meta/0007_snapshot.json
Normal file
3258
packages/db/src/migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,13 @@
|
|||||||
"when": 1771545602000,
|
"when": 1771545602000,
|
||||||
"tag": "0006_overjoyed_mister_sinister",
|
"tag": "0006_overjoyed_mister_sinister",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1771545603000,
|
||||||
|
"tag": "0007_new_quentin_quire",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
39
packages/db/src/schema/agent_task_sessions.ts
Normal file
39
packages/db/src/schema/agent_task_sessions.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||||
|
import { companies } from "./companies.js";
|
||||||
|
import { agents } from "./agents.js";
|
||||||
|
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||||
|
|
||||||
|
export const agentTaskSessions = pgTable(
|
||||||
|
"agent_task_sessions",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||||
|
agentId: uuid("agent_id").notNull().references(() => agents.id),
|
||||||
|
adapterType: text("adapter_type").notNull(),
|
||||||
|
taskKey: text("task_key").notNull(),
|
||||||
|
sessionParamsJson: jsonb("session_params_json").$type<Record<string, unknown>>(),
|
||||||
|
sessionDisplayId: text("session_display_id"),
|
||||||
|
lastRunId: uuid("last_run_id").references(() => heartbeatRuns.id),
|
||||||
|
lastError: text("last_error"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
companyAgentTaskUniqueIdx: uniqueIndex("agent_task_sessions_company_agent_adapter_task_uniq").on(
|
||||||
|
table.companyId,
|
||||||
|
table.agentId,
|
||||||
|
table.adapterType,
|
||||||
|
table.taskKey,
|
||||||
|
),
|
||||||
|
companyAgentUpdatedIdx: index("agent_task_sessions_company_agent_updated_idx").on(
|
||||||
|
table.companyId,
|
||||||
|
table.agentId,
|
||||||
|
table.updatedAt,
|
||||||
|
),
|
||||||
|
companyTaskUpdatedIdx: index("agent_task_sessions_company_task_updated_idx").on(
|
||||||
|
table.companyId,
|
||||||
|
table.taskKey,
|
||||||
|
table.updatedAt,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -3,6 +3,7 @@ export { agents } from "./agents.js";
|
|||||||
export { agentConfigRevisions } from "./agent_config_revisions.js";
|
export { agentConfigRevisions } from "./agent_config_revisions.js";
|
||||||
export { agentApiKeys } from "./agent_api_keys.js";
|
export { agentApiKeys } from "./agent_api_keys.js";
|
||||||
export { agentRuntimeState } from "./agent_runtime_state.js";
|
export { agentRuntimeState } from "./agent_runtime_state.js";
|
||||||
|
export { agentTaskSessions } from "./agent_task_sessions.js";
|
||||||
export { agentWakeupRequests } from "./agent_wakeup_requests.js";
|
export { agentWakeupRequests } from "./agent_wakeup_requests.js";
|
||||||
export { projects } from "./projects.js";
|
export { projects } from "./projects.js";
|
||||||
export { goals } from "./goals.js";
|
export { goals } from "./goals.js";
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export type {
|
|||||||
HeartbeatRun,
|
HeartbeatRun,
|
||||||
HeartbeatRunEvent,
|
HeartbeatRunEvent,
|
||||||
AgentRuntimeState,
|
AgentRuntimeState,
|
||||||
|
AgentTaskSession,
|
||||||
AgentWakeupRequest,
|
AgentWakeupRequest,
|
||||||
LiveEvent,
|
LiveEvent,
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
@@ -68,6 +69,7 @@ export {
|
|||||||
updateAgentSchema,
|
updateAgentSchema,
|
||||||
createAgentKeySchema,
|
createAgentKeySchema,
|
||||||
wakeAgentSchema,
|
wakeAgentSchema,
|
||||||
|
resetAgentSessionSchema,
|
||||||
agentPermissionsSchema,
|
agentPermissionsSchema,
|
||||||
updateAgentPermissionsSchema,
|
updateAgentPermissionsSchema,
|
||||||
type CreateAgent,
|
type CreateAgent,
|
||||||
@@ -75,6 +77,7 @@ export {
|
|||||||
type UpdateAgent,
|
type UpdateAgent,
|
||||||
type CreateAgentKey,
|
type CreateAgentKey,
|
||||||
type WakeAgent,
|
type WakeAgent,
|
||||||
|
type ResetAgentSession,
|
||||||
type UpdateAgentPermissions,
|
type UpdateAgentPermissions,
|
||||||
createProjectSchema,
|
createProjectSchema,
|
||||||
updateProjectSchema,
|
updateProjectSchema,
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export interface AgentRuntimeState {
|
|||||||
companyId: string;
|
companyId: string;
|
||||||
adapterType: string;
|
adapterType: string;
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
|
sessionDisplayId?: string | null;
|
||||||
|
sessionParamsJson?: Record<string, unknown> | null;
|
||||||
stateJson: Record<string, unknown>;
|
stateJson: Record<string, unknown>;
|
||||||
lastRunId: string | null;
|
lastRunId: string | null;
|
||||||
lastRunStatus: string | null;
|
lastRunStatus: string | null;
|
||||||
@@ -68,6 +70,20 @@ export interface AgentRuntimeState {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AgentTaskSession {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
agentId: string;
|
||||||
|
adapterType: string;
|
||||||
|
taskKey: string;
|
||||||
|
sessionParamsJson: Record<string, unknown> | null;
|
||||||
|
sessionDisplayId: string | null;
|
||||||
|
lastRunId: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentWakeupRequest {
|
export interface AgentWakeupRequest {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type {
|
|||||||
HeartbeatRun,
|
HeartbeatRun,
|
||||||
HeartbeatRunEvent,
|
HeartbeatRunEvent,
|
||||||
AgentRuntimeState,
|
AgentRuntimeState,
|
||||||
|
AgentTaskSession,
|
||||||
AgentWakeupRequest,
|
AgentWakeupRequest,
|
||||||
} from "./heartbeat.js";
|
} from "./heartbeat.js";
|
||||||
export type { LiveEvent } from "./live.js";
|
export type { LiveEvent } from "./live.js";
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ export const wakeAgentSchema = z.object({
|
|||||||
|
|
||||||
export type WakeAgent = z.infer<typeof wakeAgentSchema>;
|
export type WakeAgent = z.infer<typeof wakeAgentSchema>;
|
||||||
|
|
||||||
|
export const resetAgentSessionSchema = z.object({
|
||||||
|
taskKey: z.string().min(1).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ResetAgentSession = z.infer<typeof resetAgentSessionSchema>;
|
||||||
|
|
||||||
export const updateAgentPermissionsSchema = z.object({
|
export const updateAgentPermissionsSchema = z.object({
|
||||||
canCreateAgents: z.boolean(),
|
canCreateAgents: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export {
|
|||||||
updateAgentSchema,
|
updateAgentSchema,
|
||||||
createAgentKeySchema,
|
createAgentKeySchema,
|
||||||
wakeAgentSchema,
|
wakeAgentSchema,
|
||||||
|
resetAgentSessionSchema,
|
||||||
agentPermissionsSchema,
|
agentPermissionsSchema,
|
||||||
updateAgentPermissionsSchema,
|
updateAgentPermissionsSchema,
|
||||||
type CreateAgent,
|
type CreateAgent,
|
||||||
@@ -18,6 +19,7 @@ export {
|
|||||||
type UpdateAgent,
|
type UpdateAgent,
|
||||||
type CreateAgentKey,
|
type CreateAgentKey,
|
||||||
type WakeAgent,
|
type WakeAgent,
|
||||||
|
type ResetAgentSession,
|
||||||
type UpdateAgentPermissions,
|
type UpdateAgentPermissions,
|
||||||
} from "./agent.js";
|
} from "./agent.js";
|
||||||
|
|
||||||
|
|||||||
@@ -4,82 +4,13 @@ set -euo pipefail
|
|||||||
# Backup the embedded PostgreSQL database to data/backups/
|
# Backup the embedded PostgreSQL database to data/backups/
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./scripts/backup-db.sh # default: custom format (.dump)
|
# ./scripts/backup-db.sh
|
||||||
# ./scripts/backup-db.sh --sql # plain SQL format (.sql)
|
# pnpm db:backup
|
||||||
#
|
#
|
||||||
# Requires: pg_dump (brew install postgresql)
|
|
||||||
# The embedded postgres must be running (start with: pnpm dev)
|
# The embedded postgres must be running (start with: pnpm dev)
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
BACKUP_DIR="$PROJECT_ROOT/data/backups"
|
|
||||||
|
|
||||||
# Read config for port, fall back to default
|
cd "$PROJECT_ROOT"
|
||||||
PORT=54329
|
exec pnpm --filter @paperclip/db exec tsx src/backup.ts "$@"
|
||||||
CONFIG_FILE="$PROJECT_ROOT/.paperclip/config.json"
|
|
||||||
if [ -f "$CONFIG_FILE" ]; then
|
|
||||||
CONFIGURED_PORT=$(python3 -c "
|
|
||||||
import json, sys
|
|
||||||
try:
|
|
||||||
c = json.load(open('$CONFIG_FILE'))
|
|
||||||
print(c.get('database', {}).get('embeddedPostgresPort', ''))
|
|
||||||
except: pass
|
|
||||||
" 2>/dev/null || true)
|
|
||||||
if [ -n "$CONFIGURED_PORT" ]; then
|
|
||||||
PORT="$CONFIGURED_PORT"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
DB_NAME="paperclip"
|
|
||||||
DB_USER="paperclip"
|
|
||||||
DB_HOST="127.0.0.1"
|
|
||||||
|
|
||||||
# Check pg_dump is available
|
|
||||||
if ! command -v pg_dump &>/dev/null; then
|
|
||||||
echo "Error: pg_dump not found. Install with: brew install postgresql" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check the database is reachable
|
|
||||||
if ! PGPASSWORD="$DB_USER" pg_isready -h "$DB_HOST" -p "$PORT" -U "$DB_USER" &>/dev/null; then
|
|
||||||
echo "Error: Cannot connect to embedded PostgreSQL on port $PORT." >&2
|
|
||||||
echo " Make sure the server is running (pnpm dev)." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
|
|
||||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
|
||||||
|
|
||||||
# Choose format
|
|
||||||
FORMAT="custom"
|
|
||||||
EXT="dump"
|
|
||||||
if [[ "${1:-}" == "--sql" ]]; then
|
|
||||||
FORMAT="plain"
|
|
||||||
EXT="sql"
|
|
||||||
fi
|
|
||||||
|
|
||||||
BACKUP_FILE="$BACKUP_DIR/paperclip-${TIMESTAMP}.${EXT}"
|
|
||||||
|
|
||||||
echo "Backing up database '$DB_NAME' on port $PORT..."
|
|
||||||
|
|
||||||
PGPASSWORD="$DB_USER" pg_dump \
|
|
||||||
-h "$DB_HOST" \
|
|
||||||
-p "$PORT" \
|
|
||||||
-U "$DB_USER" \
|
|
||||||
-d "$DB_NAME" \
|
|
||||||
--format="$FORMAT" \
|
|
||||||
--file="$BACKUP_FILE"
|
|
||||||
|
|
||||||
SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
|
|
||||||
echo "Backup saved: $BACKUP_FILE ($SIZE)"
|
|
||||||
|
|
||||||
# Prune backups older than 30 days
|
|
||||||
PRUNED=0
|
|
||||||
find "$BACKUP_DIR" -name "paperclip-*" -mtime +30 -type f -print0 | while IFS= read -r -d '' old; do
|
|
||||||
rm "$old"
|
|
||||||
PRUNED=$((PRUNED + 1))
|
|
||||||
done
|
|
||||||
if [ "$PRUNED" -gt 0 ]; then
|
|
||||||
echo "Pruned $PRUNED backup(s) older than 30 days."
|
|
||||||
fi
|
|
||||||
|
|||||||
Reference in New Issue
Block a user