diff --git a/packages/db/src/agent-instruction-migration.test.ts b/packages/db/src/agent-instruction-migration.test.ts index 18df341de91..c758503449e 100644 --- a/packages/db/src/agent-instruction-migration.test.ts +++ b/packages/db/src/agent-instruction-migration.test.ts @@ -3,18 +3,38 @@ import { readFile } from "node:fs/promises"; import postgres from "postgres"; import { describe, expect, it } from "vitest"; import { applyPendingMigrations, inspectMigrations } from "./client.js"; -import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js"; +import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js"; const files = ["0285_legal_thunderbolts.sql", "0286_amusing_shiver_man.sql"]; const migrations = await Promise.all(files.map(async file => ({ file, sql: await readFile(new URL(`./migrations/${file}`, import.meta.url), "utf8"), }))); +// These migrations shipped in preview 14c3d810c9e05625121b3d27767aea9317b03125. +// Cloud upgrades require the existing SQL and journal rows to remain byte-identical. +const published = [ + { file: files[0]!, sqlHash: "5af82470982b273fb052c7e62c3695f56a9a7f2e6af29a6876f300f168167d11", journalHash: "00a680c4ca07980c76c03ddf58782da8168de7add5ed955b36affeaca1a2465b" }, + { file: files[1]!, sqlHash: "6400eb3bc26df081f0fb50bdd5a3b7bd45540c8148a0ea3d67564f462bf8180c", journalHash: "83f1e59908d11f3e4d43fb7b97ee6d829092a7038f8a45910f30cce15945ebe0" }, +]; +const journal = JSON.parse(await readFile(new URL("./migrations/meta/_journal.json", import.meta.url), "utf8")) as { + entries: Array<{ idx: number; when: number; tag: string; breakpoints: boolean }>; +}; + +describe("published instruction migration history", () => { + it.each(published)("preserves the published SQL and journal hashes for $file", (entry) => { + const migration = migrations.find(candidate => candidate.file === entry.file)!; + const row = journal.entries.find(candidate => `${candidate.tag}.sql` === entry.file)!; + expect(createHash("sha256").update(migration.sql).digest("hex")).toBe(entry.sqlHash); + expect(createHash("sha256").update(JSON.stringify([row.idx, row.when, row.tag, row.breakpoints])).digest("hex")) + .toBe(entry.journalHash); + }); +}); + const support = await getEmbeddedPostgresTestSupport(); const describePostgres = support.supported ? describe : describe.skip; describePostgres("instruction revision migrations", () => { - it("replays over existing revisions and pending copies without losing bytes or company constraints", async () => { - const database = await startEmbeddedPostgresTestDatabase("instruction-migration-replay-"); + it("keeps applied migration receipts, revisions, and pending copies intact on repeated upgrades", async () => { + const database = await startEmbeddedPostgresTestDatabase("instruction-migration-upgrade-"); const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} }); try { const companyId = randomUUID(), otherCompanyId = randomUUID(), agentId = randomUUID(); @@ -34,25 +54,25 @@ describePostgres("instruction revision migrations", () => { await sql`INSERT INTO agent_instruction_working_copies (run_id, company_id, agent_id, responsible_user_id, entry_file, base_revision_id, base_hash, local_root, execution_root, location, state, candidate_base64, candidate_hash) VALUES (${runId}, ${companyId}, ${agentId}, 'editor', 'AGENTS.md', ${revisionId}, ${contentHash}, '/private/copy', '/private/copy', 'local', 'pending_commit', ${contentBase64}, ${contentHash})`; - const before = { + const snapshot = async () => ({ + journal: await sql`SELECT * FROM drizzle.__drizzle_migrations ORDER BY id`, + relations: await sql`SELECT oid::text, relname FROM pg_class WHERE relname IN + ('agent_instruction_revisions', 'agent_instruction_heads', 'agent_instruction_working_copies') ORDER BY relname`, + constraints: await sql`SELECT oid::text, conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint + WHERE conrelid IN ('agent_instruction_revisions'::regclass, 'agent_instruction_heads'::regclass, + 'agent_instruction_working_copies'::regclass) ORDER BY conname`, revisions: await sql`SELECT * FROM agent_instruction_revisions`, heads: await sql`SELECT * FROM agent_instruction_heads`, copies: await sql`SELECT * FROM agent_instruction_working_copies`, - }; - for (const migration of migrations) { - await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${createHash("sha256").update(migration.sql).digest("hex")}`; - } - expect(await inspectMigrations(database.connectionString)).toMatchObject({ status: "needsMigrations", pendingMigrations: files }); - await applyPendingMigrations(database.connectionString); - // Also replay the SQL directly with every object already present. - await sql.begin(async tx => { - for (const migration of migrations) for (const statement of migration.sql.split("--> statement-breakpoint")) { - if (statement.trim()) await tx.unsafe(statement); - } }); - expect(await sql`SELECT * FROM agent_instruction_revisions`).toEqual(before.revisions); - expect(await sql`SELECT * FROM agent_instruction_heads`).toEqual(before.heads); - expect(await sql`SELECT * FROM agent_instruction_working_copies`).toEqual(before.copies); + const before = await snapshot(); + expect(before.relations).toHaveLength(3); + for (const entry of published) expect(before.journal.some(row => row.hash === entry.sqlHash)).toBe(true); + for (let attempt = 0; attempt < 2; attempt += 1) { + expect(await inspectMigrations(database.connectionString)).toMatchObject({ status: "upToDate" }); + await applyPendingMigrations(database.connectionString); + expect(await snapshot()).toEqual(before); + } await expect(sql`UPDATE agent_instruction_heads SET company_id = ${otherCompanyId} WHERE revision_id = ${revisionId}`) .rejects.toMatchObject({ code: "23503" }); await expect(sql`UPDATE agent_instruction_working_copies SET company_id = ${otherCompanyId} WHERE run_id = ${runId}`) @@ -61,5 +81,5 @@ describePostgres("instruction revision migrations", () => { await sql.end(); await database.cleanup(); } - }, 30_000); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); }); diff --git a/packages/db/src/migrations/0285_legal_thunderbolts.sql b/packages/db/src/migrations/0285_legal_thunderbolts.sql index 4835c499bdf..27522ed65a9 100644 --- a/packages/db/src/migrations/0285_legal_thunderbolts.sql +++ b/packages/db/src/migrations/0285_legal_thunderbolts.sql @@ -1,5 +1,4 @@ --- Keep pre-release or renumbered migration replay safe without replacing stored instruction bytes. -CREATE TABLE IF NOT EXISTS "agent_instruction_heads" ( +CREATE TABLE "agent_instruction_heads" ( "company_id" uuid NOT NULL, "agent_id" uuid NOT NULL, "entry_file" text NOT NULL, @@ -8,7 +7,7 @@ CREATE TABLE IF NOT EXISTS "agent_instruction_heads" ( CONSTRAINT "agent_instruction_heads_identity_uq" UNIQUE("company_id","agent_id","entry_file") ); --> statement-breakpoint -CREATE TABLE IF NOT EXISTS "agent_instruction_revisions" ( +CREATE TABLE "agent_instruction_revisions" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "company_id" uuid NOT NULL, "agent_id" uuid NOT NULL, @@ -28,12 +27,6 @@ CREATE TABLE IF NOT EXISTS "agent_instruction_revisions" ( CONSTRAINT "agent_instruction_revisions_identity_uq" UNIQUE("company_id","agent_id","entry_file","id") ); --> statement-breakpoint -DO $$ BEGIN -ALTER TABLE "agent_instruction_heads" ADD CONSTRAINT "agent_instruction_heads_company_id_agent_id_entry_file_revision_id_agent_instruction_revisions_company_id_agent_id_entry_file_id_fk" FOREIGN KEY ("company_id","agent_id","entry_file","revision_id") REFERENCES "public"."agent_instruction_revisions"("company_id","agent_id","entry_file","id") ON DELETE cascade ON UPDATE no action; -EXCEPTION WHEN duplicate_object THEN NULL; -END $$;--> statement-breakpoint -DO $$ BEGIN -ALTER TABLE "agent_instruction_revisions" ADD CONSTRAINT "agent_instruction_revisions_company_id_agent_id_agents_company_id_id_fk" FOREIGN KEY ("company_id","agent_id") REFERENCES "public"."agents"("company_id","id") ON DELETE cascade ON UPDATE no action; -EXCEPTION WHEN duplicate_object THEN NULL; -END $$;--> statement-breakpoint -CREATE INDEX IF NOT EXISTS "agent_instruction_revisions_history_idx" ON "agent_instruction_revisions" USING btree ("company_id","agent_id","entry_file","created_at","id"); +ALTER TABLE "agent_instruction_heads" ADD CONSTRAINT "agent_instruction_heads_company_id_agent_id_entry_file_revision_id_agent_instruction_revisions_company_id_agent_id_entry_file_id_fk" FOREIGN KEY ("company_id","agent_id","entry_file","revision_id") REFERENCES "public"."agent_instruction_revisions"("company_id","agent_id","entry_file","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "agent_instruction_revisions" ADD CONSTRAINT "agent_instruction_revisions_company_id_agent_id_agents_company_id_id_fk" FOREIGN KEY ("company_id","agent_id") REFERENCES "public"."agents"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "agent_instruction_revisions_history_idx" ON "agent_instruction_revisions" USING btree ("company_id","agent_id","entry_file","created_at","id"); \ No newline at end of file diff --git a/packages/db/src/migrations/0286_amusing_shiver_man.sql b/packages/db/src/migrations/0286_amusing_shiver_man.sql index 6bededa36fe..e50df4b3053 100644 --- a/packages/db/src/migrations/0286_amusing_shiver_man.sql +++ b/packages/db/src/migrations/0286_amusing_shiver_man.sql @@ -1,5 +1,4 @@ --- Keep pre-release or renumbered migration replay safe without replacing stored instruction bytes. -CREATE TABLE IF NOT EXISTS "agent_instruction_working_copies" ( +CREATE TABLE "agent_instruction_working_copies" ( "run_id" uuid PRIMARY KEY NOT NULL, "company_id" uuid NOT NULL, "agent_id" uuid NOT NULL, @@ -23,13 +22,7 @@ CREATE TABLE IF NOT EXISTS "agent_instruction_working_copies" ( "updated_at" timestamp with time zone DEFAULT now() NOT NULL ); --> statement-breakpoint -DO $$ BEGIN -ALTER TABLE "agent_instruction_working_copies" ADD CONSTRAINT "agent_instruction_working_copies_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE cascade ON UPDATE no action; -EXCEPTION WHEN duplicate_object THEN NULL; -END $$;--> statement-breakpoint -DO $$ BEGIN -ALTER TABLE "agent_instruction_working_copies" ADD CONSTRAINT "agent_instruction_working_copies_company_id_agent_id_agents_company_id_id_fk" FOREIGN KEY ("company_id","agent_id") REFERENCES "public"."agents"("company_id","id") ON DELETE cascade ON UPDATE no action; -EXCEPTION WHEN duplicate_object THEN NULL; -END $$;--> statement-breakpoint -CREATE INDEX IF NOT EXISTS "agent_instruction_copies_pending_idx" ON "agent_instruction_working_copies" USING btree ("state","next_attempt_at");--> statement-breakpoint -CREATE INDEX IF NOT EXISTS "agent_instruction_copies_agent_idx" ON "agent_instruction_working_copies" USING btree ("company_id","agent_id","created_at"); +ALTER TABLE "agent_instruction_working_copies" ADD CONSTRAINT "agent_instruction_working_copies_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "agent_instruction_working_copies" ADD CONSTRAINT "agent_instruction_working_copies_company_id_agent_id_agents_company_id_id_fk" FOREIGN KEY ("company_id","agent_id") REFERENCES "public"."agents"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "agent_instruction_copies_pending_idx" ON "agent_instruction_working_copies" USING btree ("state","next_attempt_at");--> statement-breakpoint +CREATE INDEX "agent_instruction_copies_agent_idx" ON "agent_instruction_working_copies" USING btree ("company_id","agent_id","created_at"); \ No newline at end of file