diff --git a/packages/db/src/client.test.ts b/packages/db/src/client.test.ts index fc1837962f..5a5a82697a 100644 --- a/packages/db/src/client.test.ts +++ b/packages/db/src/client.test.ts @@ -47,6 +47,10 @@ const migrationUpdatedAtUpdateAllowlist = new Map>([ "0131_repair_run_responsible_user_context_refs.sql", new Set(["heartbeat_runs"]), ], + [ + "0135_repair_run_responsible_user_updated_at_sweep.sql", + new Set(["companies", "heartbeat_runs", "issues", "routine_runs", "routines"]), + ], ]); function findUserVisibleUpdatedAtBackfillViolations( @@ -614,7 +618,7 @@ describeEmbeddedPostgres("applyPendingMigrations", () => { ); it( - "replays migration 0130 without bumping issue updated_at for inbox archives", + "replays migration 0134 without bumping issue updated_at for inbox archives", async () => { const connectionString = await createTempDatabase(); @@ -623,7 +627,7 @@ describeEmbeddedPostgres("applyPendingMigrations", () => { const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); try { const runResponsibleUserHash = await migrationHash( - "0130_run_responsible_user_invariant.sql", + "0134_run_responsible_user_invariant.sql", ); await sql.unsafe(` @@ -708,7 +712,7 @@ describeEmbeddedPostgres("applyPendingMigrations", () => { const pendingState = await inspectMigrations(connectionString); expect(pendingState).toMatchObject({ status: "needsMigrations", - pendingMigrations: ["0130_run_responsible_user_invariant.sql"], + pendingMigrations: ["0134_run_responsible_user_invariant.sql"], reason: "pending-migrations", }); @@ -746,6 +750,402 @@ describeEmbeddedPostgres("applyPendingMigrations", () => { 20_000, ); + it( + "replays migration 0135 to repair updated_at sweeps and no-op when clean", + async () => { + const connectionString = await createTempDatabase(); + + await applyPendingMigrations(connectionString); + + const repairSweepHash = await migrationHash( + "0135_repair_run_responsible_user_updated_at_sweep.sql", + ); + const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); + try { + await sql.unsafe(` + INSERT INTO "companies" ("id", "name", "issue_prefix", "created_at", "updated_at") + VALUES ( + '00000000-0000-0000-0000-000000000240', + 'Clean Migration Co', + 'CLN134', + '2026-04-01T09:00:00.000Z', + '2026-04-02T09:00:00.000Z' + ) + `); + await sql.unsafe(` + INSERT INTO "issues" ("id", "company_id", "title", "status", "created_at", "updated_at") + VALUES ( + '00000000-0000-0000-0000-000000000241', + '00000000-0000-0000-0000-000000000240', + 'Clean issue should not be touched', + 'todo', + '2026-04-01T10:00:00.000Z', + '2026-04-02T10:00:00.000Z' + ) + `); + await sql.unsafe( + `DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${repairSweepHash}'`, + ); + } finally { + await sql.end(); + } + + await applyPendingMigrations(connectionString); + + const afterCleanReplay = postgres(connectionString, { max: 1, onnotice: () => {} }); + try { + const cleanRows = await afterCleanReplay.unsafe<{ updated_at: Date }[]>(` + SELECT "updated_at" + FROM "issues" + WHERE "id" = '00000000-0000-0000-0000-000000000241' + `); + expect(cleanRows[0]?.updated_at.toISOString()).toBe("2026-04-02T10:00:00.000Z"); + + await afterCleanReplay.unsafe(` + INSERT INTO "companies" ("id", "name", "issue_prefix", "created_at", "updated_at") + VALUES ( + '00000000-0000-0000-0000-000000000250', + 'Sweep Migration Co', + 'SWP134', + '2026-01-01T00:00:00.000Z', + '2026-04-03T12:00:00.123456Z' + ) + `); + await afterCleanReplay.unsafe(` + INSERT INTO "agents" ("id", "company_id", "name", "role", "adapter_type", "created_at", "updated_at") + VALUES ( + '00000000-0000-0000-0000-000000000251', + '00000000-0000-0000-0000-000000000250', + 'Sweep Agent', + 'general', + 'process', + '2026-01-02T00:00:00.000Z', + '2026-01-02T00:00:00.000Z' + ) + `); + await afterCleanReplay.unsafe(` + INSERT INTO "issues" ("id", "company_id", "title", "status", "created_at", "updated_at") + SELECT + ('10000000-0000-0000-0000-' || lpad(gs::text, 12, '0'))::uuid, + '00000000-0000-0000-0000-000000000250', + 'Swept issue ' || gs::text, + 'todo', + '2026-02-01T00:00:00.000Z'::timestamptz + (gs::text || ' minutes')::interval, + '2026-04-03T12:00:00.123456Z' + FROM generate_series(1, 101) AS gs + `); + await afterCleanReplay.unsafe(` + UPDATE "issues" + SET + "status" = 'done', + "completed_at" = '2026-04-03T12:00:00.123456Z' + WHERE "id" = '10000000-0000-0000-0000-000000000003' + `); + await afterCleanReplay.unsafe(` + INSERT INTO "issue_comments" ("id", "company_id", "issue_id", "body", "created_at", "updated_at") + VALUES ( + '00000000-0000-0000-0000-000000000252', + '00000000-0000-0000-0000-000000000250', + '10000000-0000-0000-0000-000000000001', + 'Latest pre-sweep activity', + '2026-03-01T15:30:00.000Z', + '2026-03-02T16:45:00.000Z' + ) + `); + await afterCleanReplay.unsafe(` + INSERT INTO "heartbeat_runs" ( + "id", + "company_id", + "agent_id", + "status", + "started_at", + "finished_at", + "created_at", + "updated_at" + ) + VALUES ( + '00000000-0000-0000-0000-000000000253', + '00000000-0000-0000-0000-000000000250', + '00000000-0000-0000-0000-000000000251', + 'completed', + '2026-02-10T10:00:00.000Z', + '2026-02-10T10:30:00.000Z', + '2026-02-10T09:55:00.000Z', + '2026-04-03T12:00:00.123456Z' + ) + `); + await afterCleanReplay.unsafe(` + INSERT INTO "heartbeat_runs" ( + "id", + "company_id", + "agent_id", + "status", + "started_at", + "last_output_at", + "created_at", + "updated_at" + ) + VALUES ( + '00000000-0000-0000-0000-000000000256', + '00000000-0000-0000-0000-000000000250', + '00000000-0000-0000-0000-000000000251', + 'running', + '2026-02-10T11:00:00.000Z', + '2026-04-03T12:00:00.123456Z', + '2026-02-10T10:55:00.000Z', + '2026-04-03T12:00:00.123456Z' + ) + `); + await afterCleanReplay.unsafe(` + INSERT INTO "routines" ( + "id", + "company_id", + "title", + "last_triggered_at", + "last_enqueued_at", + "created_at", + "updated_at" + ) + VALUES ( + '00000000-0000-0000-0000-000000000254', + '00000000-0000-0000-0000-000000000250', + 'Swept routine', + '2026-03-20T10:00:00.000Z', + '2026-03-21T11:00:00.000Z', + '2026-02-11T00:00:00.000Z', + '2026-04-03T12:00:00.123456Z' + ) + `); + await afterCleanReplay.unsafe(` + INSERT INTO "routines" ( + "id", + "company_id", + "title", + "last_triggered_at", + "last_enqueued_at", + "created_at", + "updated_at" + ) + VALUES ( + '00000000-0000-0000-0000-000000000257', + '00000000-0000-0000-0000-000000000250', + 'Same-timestamp active routine', + '2026-03-20T10:00:00.000Z', + '2026-04-03T12:00:00.123456Z', + '2026-02-11T00:00:00.000Z', + '2026-04-03T12:00:00.123456Z' + ) + `); + await afterCleanReplay.unsafe(` + INSERT INTO "routine_runs" ( + "id", + "company_id", + "routine_id", + "source", + "status", + "completed_at", + "created_at", + "updated_at" + ) + VALUES ( + '00000000-0000-0000-0000-000000000255', + '00000000-0000-0000-0000-000000000250', + '00000000-0000-0000-0000-000000000254', + 'schedule', + 'completed', + '2026-02-12T12:00:00.000Z', + '2026-02-12T11:00:00.000Z', + '2026-04-03T12:00:00.123456Z' + ) + `); + await afterCleanReplay.unsafe(` + INSERT INTO "routine_runs" ( + "id", + "company_id", + "routine_id", + "source", + "status", + "triggered_at", + "created_at", + "updated_at" + ) + VALUES ( + '00000000-0000-0000-0000-000000000258', + '00000000-0000-0000-0000-000000000250', + '00000000-0000-0000-0000-000000000257', + 'schedule', + 'running', + '2026-04-03T12:00:00.123456Z', + '2026-02-12T13:00:00.000Z', + '2026-04-03T12:00:00.123456Z' + ) + `); + await afterCleanReplay.unsafe(` + INSERT INTO "companies" ("id", "name", "issue_prefix", "created_at", "updated_at") + VALUES ( + '00000000-0000-0000-0000-000000000260', + 'Coincident Timestamp Co', + 'CTS134', + '2026-01-05T00:00:00.000Z', + '2026-04-03T12:00:00.123456Z' + ) + `); + await afterCleanReplay.unsafe(` + INSERT INTO "agents" ("id", "company_id", "name", "role", "adapter_type", "created_at", "updated_at") + VALUES ( + '00000000-0000-0000-0000-000000000261', + '00000000-0000-0000-0000-000000000260', + 'Coincident Agent', + 'general', + 'process', + '2026-01-05T00:10:00.000Z', + '2026-01-05T00:10:00.000Z' + ) + `); + await afterCleanReplay.unsafe(` + INSERT INTO "issues" ("id", "company_id", "title", "status", "created_at", "updated_at") + VALUES ( + '20000000-0000-0000-0000-000000000001', + '00000000-0000-0000-0000-000000000260', + 'Coincident timestamp issue should not be touched', + 'todo', + '2026-02-05T00:00:00.000Z', + '2026-04-03T12:00:00.123456Z' + ) + `); + await afterCleanReplay.unsafe(` + INSERT INTO "heartbeat_runs" ( + "id", + "company_id", + "agent_id", + "status", + "started_at", + "finished_at", + "created_at", + "updated_at" + ) + VALUES ( + '00000000-0000-0000-0000-000000000262', + '00000000-0000-0000-0000-000000000260', + '00000000-0000-0000-0000-000000000261', + 'completed', + '2026-02-05T10:00:00.000Z', + '2026-02-05T10:30:00.000Z', + '2026-02-05T09:55:00.000Z', + '2026-04-03T12:00:00.123456Z' + ) + `); + await afterCleanReplay.unsafe( + `DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${repairSweepHash}'`, + ); + } finally { + await afterCleanReplay.end(); + } + + await applyPendingMigrations(connectionString); + + const afterRepair = postgres(connectionString, { max: 1, onnotice: () => {} }); + try { + const repairedRows = await afterRepair.unsafe<{ + subject: string; + updated_at: Date; + }[]>(` + SELECT 'company' AS subject, "updated_at" + FROM "companies" + WHERE "id" = '00000000-0000-0000-0000-000000000250' + UNION ALL + SELECT 'issue_with_comment' AS subject, "updated_at" + FROM "issues" + WHERE "id" = '10000000-0000-0000-0000-000000000001' + UNION ALL + SELECT 'issue_without_comment' AS subject, "updated_at" + FROM "issues" + WHERE "id" = '10000000-0000-0000-0000-000000000002' + UNION ALL + SELECT 'issue_with_state_activity' AS subject, "updated_at" + FROM "issues" + WHERE "id" = '10000000-0000-0000-0000-000000000003' + UNION ALL + SELECT 'heartbeat_run' AS subject, "updated_at" + FROM "heartbeat_runs" + WHERE "id" = '00000000-0000-0000-0000-000000000253' + UNION ALL + SELECT 'heartbeat_run_with_output' AS subject, "updated_at" + FROM "heartbeat_runs" + WHERE "id" = '00000000-0000-0000-0000-000000000256' + UNION ALL + SELECT 'other_company' AS subject, "updated_at" + FROM "companies" + WHERE "id" = '00000000-0000-0000-0000-000000000260' + UNION ALL + SELECT 'other_heartbeat_run' AS subject, "updated_at" + FROM "heartbeat_runs" + WHERE "id" = '00000000-0000-0000-0000-000000000262' + UNION ALL + SELECT 'other_issue' AS subject, "updated_at" + FROM "issues" + WHERE "id" = '20000000-0000-0000-0000-000000000001' + UNION ALL + SELECT 'routine' AS subject, "updated_at" + FROM "routines" + WHERE "id" = '00000000-0000-0000-0000-000000000254' + UNION ALL + SELECT 'routine_with_activity' AS subject, "updated_at" + FROM "routines" + WHERE "id" = '00000000-0000-0000-0000-000000000257' + UNION ALL + SELECT 'routine_run' AS subject, "updated_at" + FROM "routine_runs" + WHERE "id" = '00000000-0000-0000-0000-000000000255' + UNION ALL + SELECT 'routine_run_with_trigger' AS subject, "updated_at" + FROM "routine_runs" + WHERE "id" = '00000000-0000-0000-0000-000000000258' + ORDER BY subject + `); + const repaired = Object.fromEntries( + repairedRows.map((row) => [row.subject, row.updated_at.toISOString()]), + ); + expect(repaired).toEqual({ + company: "2026-01-01T00:00:00.000Z", + heartbeat_run: "2026-02-10T10:30:00.000Z", + heartbeat_run_with_output: "2026-04-03T12:00:00.123Z", + issue_with_comment: "2026-03-02T16:45:00.000Z", + issue_with_state_activity: "2026-04-03T12:00:00.123Z", + issue_without_comment: "2026-02-01T00:02:00.000Z", + other_company: "2026-04-03T12:00:00.123Z", + other_heartbeat_run: "2026-04-03T12:00:00.123Z", + other_issue: "2026-04-03T12:00:00.123Z", + routine: "2026-03-21T11:00:00.000Z", + routine_run_with_trigger: "2026-04-03T12:00:00.123Z", + routine_with_activity: "2026-04-03T12:00:00.123Z", + routine_run: "2026-02-12T12:00:00.000Z", + }); + + await afterRepair.unsafe( + `DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${repairSweepHash}'`, + ); + } finally { + await afterRepair.end(); + } + + await applyPendingMigrations(connectionString); + + const afterSecondRun = postgres(connectionString, { max: 1, onnotice: () => {} }); + try { + const secondRunRows = await afterSecondRun.unsafe<{ updated_at: Date }[]>(` + SELECT "updated_at" + FROM "issues" + WHERE "id" = '10000000-0000-0000-0000-000000000001' + `); + expect(secondRunRows[0]?.updated_at.toISOString()).toBe("2026-03-02T16:45:00.000Z"); + } finally { + await afterSecondRun.end(); + } + }, + 20_000, + ); + it( "replays the run responsible user repair migration when heartbeat run issue refs are identifiers", async () => { diff --git a/packages/db/src/migrations/0130_run_responsible_user_invariant.sql b/packages/db/src/migrations/0134_run_responsible_user_invariant.sql similarity index 100% rename from packages/db/src/migrations/0130_run_responsible_user_invariant.sql rename to packages/db/src/migrations/0134_run_responsible_user_invariant.sql diff --git a/packages/db/src/migrations/0135_repair_run_responsible_user_updated_at_sweep.sql b/packages/db/src/migrations/0135_repair_run_responsible_user_updated_at_sweep.sql new file mode 100644 index 0000000000..b346dd1ea8 --- /dev/null +++ b/packages/db/src/migrations/0135_repair_run_responsible_user_updated_at_sweep.sql @@ -0,0 +1,84 @@ +DROP TABLE IF EXISTS "run_responsible_user_updated_at_sweeps"; +--> statement-breakpoint +CREATE TEMP TABLE "run_responsible_user_updated_at_sweeps" ON COMMIT DROP AS +SELECT i."company_id", i."updated_at" AS "sweep_at" +FROM "issues" AS i +WHERE EXISTS ( + SELECT 1 + FROM "heartbeat_runs" AS h + WHERE h."company_id" = i."company_id" + AND h."updated_at" = i."updated_at" + ) + AND EXISTS ( + SELECT 1 + FROM "companies" AS c + WHERE c."id" = i."company_id" + AND c."updated_at" = i."updated_at" + ) +GROUP BY i."company_id", i."updated_at" +HAVING count(*) > 100; +--> statement-breakpoint +UPDATE "issues" AS i +SET "updated_at" = GREATEST( + i."created_at", + COALESCE(CASE WHEN i."started_at" <= sweep."sweep_at" THEN i."started_at" END, i."created_at"), + COALESCE(CASE WHEN i."completed_at" <= sweep."sweep_at" THEN i."completed_at" END, i."created_at"), + COALESCE(CASE WHEN i."cancelled_at" <= sweep."sweep_at" THEN i."cancelled_at" END, i."created_at"), + COALESCE(CASE WHEN i."monitor_wake_requested_at" <= sweep."sweep_at" THEN i."monitor_wake_requested_at" END, i."created_at"), + COALESCE(CASE WHEN i."monitor_last_triggered_at" <= sweep."sweep_at" THEN i."monitor_last_triggered_at" END, i."created_at"), + COALESCE( + ( + SELECT max(GREATEST(c."created_at", c."updated_at")) + FROM "issue_comments" AS c + WHERE c."company_id" = i."company_id" + AND c."issue_id" = i."id" + AND c."created_at" <= sweep."sweep_at" + AND c."updated_at" <= sweep."sweep_at" + ), + i."created_at" + ) +) +FROM "run_responsible_user_updated_at_sweeps" AS sweep +WHERE i."company_id" = sweep."company_id" + AND i."updated_at" = sweep."sweep_at"; +--> statement-breakpoint +UPDATE "heartbeat_runs" AS h +SET "updated_at" = GREATEST( + h."created_at", + COALESCE(CASE WHEN h."started_at" <= sweep."sweep_at" THEN h."started_at" END, h."created_at"), + COALESCE(CASE WHEN h."process_started_at" <= sweep."sweep_at" THEN h."process_started_at" END, h."created_at"), + COALESCE(CASE WHEN h."last_output_at" <= sweep."sweep_at" THEN h."last_output_at" END, h."created_at"), + COALESCE(CASE WHEN h."scheduled_retry_at" <= sweep."sweep_at" THEN h."scheduled_retry_at" END, h."created_at"), + COALESCE(CASE WHEN h."finished_at" <= sweep."sweep_at" THEN h."finished_at" END, h."created_at") +) +FROM "run_responsible_user_updated_at_sweeps" AS sweep +WHERE h."company_id" = sweep."company_id" + AND h."updated_at" = sweep."sweep_at"; +--> statement-breakpoint +UPDATE "routine_runs" AS rr +SET "updated_at" = GREATEST( + rr."created_at", + COALESCE(CASE WHEN rr."triggered_at" <= sweep."sweep_at" THEN rr."triggered_at" END, rr."created_at"), + COALESCE(CASE WHEN rr."completed_at" <= sweep."sweep_at" THEN rr."completed_at" END, rr."created_at") +) +FROM "run_responsible_user_updated_at_sweeps" AS sweep +WHERE rr."company_id" = sweep."company_id" + AND rr."updated_at" = sweep."sweep_at"; +--> statement-breakpoint +UPDATE "routines" AS r +SET "updated_at" = GREATEST( + r."created_at", + COALESCE(CASE WHEN r."last_triggered_at" <= sweep."sweep_at" THEN r."last_triggered_at" END, r."created_at"), + COALESCE(CASE WHEN r."last_enqueued_at" <= sweep."sweep_at" THEN r."last_enqueued_at" END, r."created_at") +) +FROM "run_responsible_user_updated_at_sweeps" AS sweep +WHERE r."company_id" = sweep."company_id" + AND r."updated_at" = sweep."sweep_at"; +--> statement-breakpoint +UPDATE "companies" AS c +SET "updated_at" = c."created_at" +FROM "run_responsible_user_updated_at_sweeps" AS sweep +WHERE c."id" = sweep."company_id" + AND c."updated_at" = sweep."sweep_at"; +--> statement-breakpoint +DROP TABLE IF EXISTS "run_responsible_user_updated_at_sweeps"; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 0152df8813..5118110ea8 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -905,13 +905,6 @@ "tag": "0129_agent_api_key_responsible_user", "breakpoints": true }, - { - "idx": 130, - "version": "7", - "when": 1783025224120, - "tag": "0130_run_responsible_user_invariant", - "breakpoints": true - }, { "idx": 131, "version": "7", @@ -932,6 +925,20 @@ "when": 1783034521000, "tag": "0133_resource_membership_stars", "breakpoints": true + }, + { + "idx": 134, + "version": "7", + "when": 1783025624120, + "tag": "0134_run_responsible_user_invariant", + "breakpoints": true + }, + { + "idx": 135, + "version": "7", + "when": 1783025724120, + "tag": "0135_repair_run_responsible_user_updated_at_sweep", + "breakpoints": true } ] }