mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 16:58:52 +01:00
Merge qualified backup expiration parent into GitHub connection work
Co-Authored-By: Paperclip <noreply@paperclip.ing> * commit 'aae32f2f493848199f6cb8c0a9996af3ee0061b1': (30 commits) Scope status-card refresh tasks to their claim revision test(chat): retain completion identity before link metadata loads test(chat): locate completion through its rendered task link fix(chat): recover committed native Agent Chat replies (#18465) perf(heartbeat): drive retry promotion from durable deadlines (#18387) fix(ui): make the Coordinator composer footer opaque (#18455) fix(testops): verify unexpanded result job runs (#18462) test(server): observe restored Retry before draining unrelated work test(runner): own listener cleanup across setup failures test(runner): buffer upgrade frames until the wire subscribes fix(runner): preserve OpenCode written-answer defaults (#18456) fix(native): fence retries when replaying an audited Stop (#18457) test: await async outcomes and drive saved deadlines deterministically (#18416) fix(ui): retain startup autosave through session recovery (#18412) perf(server): recover independent native replies concurrently (#18421) perf(chat): scope task questions and batch inbox queue checks (#18422) fix(native): recover controllers across webserver replacement (#18343) fix(ui): restore task document authoring and add persistence journey (#18356) fix(tools): stabilize tied profile entry order (#18427) fix(adapter-utils): tolerate entries removed during workspace snapshots (#18426) ...
This commit is contained in:
commit
f522ab07e2
484 files changed
+33834
-1620
No files matched your search
@@ -0,0 +1,63 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import postgres from "postgres";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyPendingMigrations } from "./client.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const indexName = "issue_comments_agent_client_request_uq";
|
||||
const migrationFile = "0419_slippery_impossible_man.sql";
|
||||
|
||||
(support.supported ? describe : describe.skip)("agent chat message migration", () => {
|
||||
it("repairs an interrupted index build and backfills old chat owners only once", async () => {
|
||||
const database = await startEmbeddedPostgresTestDatabase("agent-chat-message-migration-");
|
||||
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const content = await readFile(new URL(`./migrations/${migrationFile}`, import.meta.url), "utf8");
|
||||
const hash = createHash("sha256").update(content).digest("hex");
|
||||
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash=${hash}`;
|
||||
await sql.unsafe(`ALTER TABLE issue_comments DROP CONSTRAINT ${indexName}`);
|
||||
const companies = await sql`INSERT INTO companies (name,issue_prefix) VALUES ('Chat migration','CMI1'),('Other company','CMI2') RETURNING id`;
|
||||
const companyId = companies[0]!.id;
|
||||
const [agent] = await sql`INSERT INTO agents (company_id,name,adapter_type) VALUES (${companyId},'Kodi','process') RETURNING id`;
|
||||
const chats = await sql`INSERT INTO issues (company_id,title,conversation_agent_id,conversation_user_id,conversation_state,assignee_agent_id)
|
||||
VALUES (${companyId},'Missing read state',${agent!.id},'legacy-owner','waiting',${agent!.id}),
|
||||
(${companyId},'Existing read state',${agent!.id},'existing-owner','waiting',${agent!.id}) RETURNING id,conversation_user_id`;
|
||||
for (const chat of chats) {
|
||||
await sql`INSERT INTO issue_comments (company_id,issue_id,author_agent_id,body,created_at)
|
||||
VALUES (${companyId},${chat.id},${agent!.id},'Existing message','2026-01-02T00:00:00Z')`;
|
||||
}
|
||||
await sql`INSERT INTO issue_read_states (company_id,issue_id,user_id,last_read_at)
|
||||
VALUES (${companyId},${chats[1]!.id},'existing-owner','2026-01-01T00:00:00Z')`;
|
||||
await expect(sql.unsafe(`CREATE UNIQUE INDEX CONCURRENTLY ${indexName} ON companies ((true))`)).rejects.toThrow();
|
||||
await Promise.all([applyPendingMigrations(database.connectionString), applyPendingMigrations(database.connectionString)]);
|
||||
const [index] = await sql`SELECT indisvalid,indisunique FROM pg_index WHERE indexrelid=${indexName}::regclass`;
|
||||
expect(index).toEqual({ indisvalid: true, indisunique: true });
|
||||
const [constraint] = await sql`SELECT count(*)::int count FROM pg_constraint WHERE conname=${indexName} AND conrelid='issue_comments'::regclass`;
|
||||
expect(constraint!.count).toBe(1);
|
||||
const readStates = await sql`SELECT user_id,last_read_at FROM issue_read_states WHERE company_id=${companyId} ORDER BY user_id`;
|
||||
expect(readStates.map(row => [row.user_id, row.last_read_at.toISOString()])).toEqual([
|
||||
["existing-owner", "2026-01-01T00:00:00.000Z"],
|
||||
["legacy-owner", "2026-01-02T00:00:00.000Z"],
|
||||
]);
|
||||
const [newChat] = await sql`INSERT INTO issues (company_id,title,conversation_agent_id,conversation_user_id,conversation_state,assignee_agent_id)
|
||||
VALUES (${companyId},'New unread chat',${agent!.id},'new-owner','waiting',${agent!.id}) RETURNING id`;
|
||||
await sql`INSERT INTO issue_comments (company_id,issue_id,author_agent_id,body)
|
||||
VALUES (${companyId},${newChat!.id},${agent!.id},'New digest')`;
|
||||
for (let replay = 0; replay < 2; replay++) {
|
||||
for (const statement of content.split("--> statement-breakpoint").filter(statement => statement.trim())) {
|
||||
await sql.unsafe(statement);
|
||||
}
|
||||
}
|
||||
expect(await sql`SELECT id FROM issue_read_states WHERE issue_id=${newChat!.id}`).toEqual([]);
|
||||
const afterReplay = await sql`SELECT user_id,last_read_at FROM issue_read_states WHERE company_id=${companyId} ORDER BY user_id`;
|
||||
expect(afterReplay).toEqual(readStates);
|
||||
const [history] = await sql`SELECT count(*)::int count FROM drizzle.__drizzle_migrations WHERE hash=${hash}`;
|
||||
expect(history!.count).toBe(1);
|
||||
} finally {
|
||||
await sql.end();
|
||||
await database.cleanup();
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { checkpointDb, closeCheckpointDb, closeRegisteredClients, createDb, type Db } from "./client.js";
|
||||
import { checkpointDb, closeCheckpointDb, closeRegisteredClients, createDb, livenessDb, type Db } from "./client.js";
|
||||
|
||||
// postgres.js pools are lazy: these tests exercise actual client lifetimes but
|
||||
// never connect to a database or depend on embedded Postgres availability.
|
||||
@@ -17,6 +17,7 @@ it("fences checkpoints when shutdown precedes the first pool request", async ()
|
||||
it("keeps an already-created checkpoint pool ended after shutdown", async () => {
|
||||
const db = createDb(url);
|
||||
const pool = checkpointDb(db);
|
||||
expect(pool.$client.options.max).toBe(3);
|
||||
await closeCheckpointDb(db, 1);
|
||||
expect(checkpointDb(db)).toBe(pool);
|
||||
await expect(pool.$client`select 1`).rejects.toMatchObject({ code: "CONNECTION_ENDED" });
|
||||
@@ -30,3 +31,16 @@ it("preserves other owner lifetimes and non-owning test-double behavior", async
|
||||
await closeCheckpointDb(nonOwner, 1);
|
||||
expect(checkpointDb(nonOwner)).toBe(nonOwner);
|
||||
});
|
||||
|
||||
it("bounds one cached checkpoint pool per owner without changing request or renewal capacity", () => {
|
||||
const first = createDb(url, { maxConnections: 10 });
|
||||
const second = createDb(url, { maxConnections: 10 });
|
||||
const pool = checkpointDb(first);
|
||||
expect(pool).not.toBe(first);
|
||||
expect(checkpointDb(first)).toBe(pool);
|
||||
expect(checkpointDb(second)).not.toBe(pool);
|
||||
expect(pool.$client.options.max).toBe(3);
|
||||
expect(checkpointDb(second).$client.options.max).toBe(3);
|
||||
expect(first.$client.options.max).toBe(10);
|
||||
expect(livenessDb(first).$client.options.max).toBe(2);
|
||||
});
|
||||
@@ -295,9 +295,10 @@ export async function closeLivenessDb(db: Db, timeoutSeconds: number): Promise<v
|
||||
}
|
||||
|
||||
// Control checkpoints must not occupy the request or lease-renewal pools
|
||||
// during an aligned burst. Share at most two lazy connections per owner.
|
||||
// during an aligned burst. Share at most three lazy connections per owner.
|
||||
const checkpointDbFactories = new WeakMap<object, () => Db>();
|
||||
const checkpointDbs = new WeakMap<object, Db>();
|
||||
const CHECKPOINT_POOL_MAX_CONNECTIONS = 3;
|
||||
const closedCheckpointOwners = new WeakSet<object>();
|
||||
|
||||
/** The bounded controller checkpoint pool; transactions and test doubles keep
|
||||
@@ -342,7 +343,7 @@ function createDbClient(url: string, resolved: DatabaseClientOptions, owner?: ob
|
||||
...resolved, maxConnections: LIVENESS_POOL_MAX_CONNECTIONS, applicationName: "paperclip-controller-lease",
|
||||
}, db));
|
||||
checkpointDbFactories.set(db, () => createDbClient(url, {
|
||||
...resolved, maxConnections: 2, applicationName: "paperclip-runner-checkpoint",
|
||||
...resolved, maxConnections: CHECKPOINT_POOL_MAX_CONNECTIONS, applicationName: "paperclip-runner-checkpoint",
|
||||
}, db));
|
||||
return db;
|
||||
}
|
||||
@@ -564,6 +565,7 @@ const MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS = new Set([
|
||||
"0412_tearful_squadron_supreme.sql",
|
||||
"0413_tired_kronos.sql",
|
||||
"0414_closed_edwin_jarvis.sql",
|
||||
"0419_slippery_impossible_man.sql",
|
||||
|
||||
|
||||
"0384_funny_adam_warlock.sql",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "issue_comments_agent_client_request_uq"
|
||||
ON "issue_comments" ("issue_id", "author_agent_id", "client_request_id");
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'issue_comments_agent_client_request_uq'
|
||||
AND conrelid = 'issue_comments'::regclass
|
||||
) THEN
|
||||
INSERT INTO "issue_read_states" ("company_id", "issue_id", "user_id", "last_read_at")
|
||||
SELECT i."company_id", i."id", i."conversation_user_id", max(c."created_at")
|
||||
FROM "issues" i
|
||||
JOIN "issue_comments" c ON c."company_id" = i."company_id" AND c."issue_id" = i."id"
|
||||
WHERE i."conversation_agent_id" IS NOT NULL AND i."conversation_user_id" IS NOT NULL
|
||||
AND c."deleted_at" IS NULL AND c."author_agent_id" IS NOT NULL
|
||||
AND c."author_type" IS DISTINCT FROM 'system'
|
||||
AND c."presentation" ->> 'kind' IS DISTINCT FROM 'system_notice'
|
||||
AND c."origin" = 'comment'
|
||||
GROUP BY i."company_id", i."id", i."conversation_user_id"
|
||||
ON CONFLICT ("company_id", "issue_id", "user_id") DO NOTHING;
|
||||
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_agent_client_request_uq"
|
||||
UNIQUE USING INDEX "issue_comments_agent_client_request_uq";
|
||||
END IF;
|
||||
END $$;
|
||||
File renamed without changes.
+10
-7
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "cce00822-b7a4-4acd-9c59-0f6f5822c0cf",
|
||||
"id": "52cf22f3-3bd9-4b0f-8d23-b422bb0d2540",
|
||||
"prevId": "c777fa84-85a6-418f-bea1-0d60b56cf071",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -37105,6 +37105,15 @@
|
||||
"client_request_id"
|
||||
]
|
||||
},
|
||||
"issue_comments_agent_client_request_uq": {
|
||||
"name": "issue_comments_agent_client_request_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"issue_id",
|
||||
"author_agent_id",
|
||||
"client_request_id"
|
||||
]
|
||||
},
|
||||
"issue_comments_company_id_uq": {
|
||||
"name": "issue_comments_company_id_uq",
|
||||
"nullsNotDistinct": false,
|
||||
@@ -52770,12 +52779,6 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"next_eval_reason": {
|
||||
"name": "next_eval_reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"next_eval_at": {
|
||||
"name": "next_eval_at",
|
||||
"type": "timestamp with time zone",
|
||||
|
||||
+299
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "dce41524-29a3-4552-ba5d-f0abefbf1ea1",
|
||||
"prevId": "27c9008c-60e3-449b-9630-9e4977fad444",
|
||||
"id": "8f75a1bb-32d9-4aed-9f93-134a6ae71f79",
|
||||
"prevId": "52cf22f3-3bd9-4b0f-8d23-b422bb0d2540",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
@@ -19052,6 +19052,98 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.company_skill_mutation_receipts": {
|
||||
"name": "company_skill_mutation_receipts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"fingerprint": {
|
||||
"name": "fingerprint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"skill_id": {
|
||||
"name": "skill_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"version_id": {
|
||||
"name": "version_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"response": {
|
||||
"name": "response",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"company_skill_mutation_receipts_key_idx": {
|
||||
"name": "company_skill_mutation_receipts_key_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "key",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"company_skill_mutation_receipts_company_id_companies_id_fk": {
|
||||
"name": "company_skill_mutation_receipts_company_id_companies_id_fk",
|
||||
"tableFrom": "company_skill_mutation_receipts",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.company_skill_repository_snapshots": {
|
||||
"name": "company_skill_repository_snapshots",
|
||||
"schema": "",
|
||||
@@ -34070,7 +34162,7 @@
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "\"heartbeat_runs\".\"runtime_mode\" = 'native' AND \"heartbeat_runs\".\"status\" = 'cancelled'\n AND \"heartbeat_runs\".\"runner_profile_json\"->'nativeWorkspaceSync'->>'state' = 'prepared'\n AND \"heartbeat_runs\".\"result_json\"->'nativeCancellation'->>'dispatchState' = 'acknowledged'\n AND (\"heartbeat_runs\".\"result_json\"->'workspaceRestoreRecovery'->>'schema' = 'paperclip.workspace-restore-recovery.v1'\n AND \"heartbeat_runs\".\"result_json\"->>'workspaceRestoreFailure' IN ('restore_permission_denied', 'restore_lock_timeout', 'restore_unsafe_archive', 'restore_failed')) IS NOT TRUE",
|
||||
"where": "\"heartbeat_runs\".\"runtime_mode\" = 'native' AND \"heartbeat_runs\".\"status\" = 'cancelled'\n AND \"heartbeat_runs\".\"runner_profile_json\"->'nativeWorkspaceSync'->>'state' = 'prepared'\n AND \"heartbeat_runs\".\"result_json\"->'nativeCancellation'->>'dispatchState' = 'acknowledged'\n AND (\"heartbeat_runs\".\"result_json\"->'workspaceRestoreRecovery'->>'schema' = 'paperclip.workspace-restore-recovery.v1'\n AND \"heartbeat_runs\".\"result_json\"->>'workspaceRestoreFailure' IN ('restore_permission_denied', 'restore_lock_timeout', 'restore_unsafe_archive', 'restore_invalid_metadata', 'restore_failed')) IS NOT TRUE",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
@@ -37013,6 +37105,15 @@
|
||||
"client_request_id"
|
||||
]
|
||||
},
|
||||
"issue_comments_agent_client_request_uq": {
|
||||
"name": "issue_comments_agent_client_request_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"issue_id",
|
||||
"author_agent_id",
|
||||
"client_request_id"
|
||||
]
|
||||
},
|
||||
"issue_comments_company_id_uq": {
|
||||
"name": "issue_comments_company_id_uq",
|
||||
"nullsNotDistinct": false,
|
||||
@@ -51482,6 +51583,195 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sandbox_creation_operations": {
|
||||
"name": "sandbox_creation_operations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"environment_id": {
|
||||
"name": "environment_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"original_environment_id": {
|
||||
"name": "original_environment_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"request_key": {
|
||||
"name": "request_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'run'"
|
||||
},
|
||||
"issue_id": {
|
||||
"name": "issue_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"execution_workspace_id": {
|
||||
"name": "execution_workspace_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"plugin_id": {
|
||||
"name": "plugin_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"driver_key": {
|
||||
"name": "driver_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"lease_id": {
|
||||
"name": "lease_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"next_attempt_at": {
|
||||
"name": "next_attempt_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"sandbox_creation_operations_pending_deadline_idx": {
|
||||
"name": "sandbox_creation_operations_pending_deadline_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "next_attempt_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "\"sandbox_creation_operations\".\"state\" = 'pending'",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"sandbox_creation_operations_owner_idx": {
|
||||
"name": "sandbox_creation_operations_owner_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "original_environment_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "run_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "request_key",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"sandbox_creation_operations_environment_id_environments_id_fk": {
|
||||
"name": "sandbox_creation_operations_environment_id_environments_id_fk",
|
||||
"tableFrom": "sandbox_creation_operations",
|
||||
"tableTo": "environments",
|
||||
"columnsFrom": [
|
||||
"environment_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.secret_access_events": {
|
||||
"name": "secret_access_events",
|
||||
"schema": "",
|
||||
@@ -52489,6 +52779,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"next_eval_reason": {
|
||||
"name": "next_eval_reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"next_eval_at": {
|
||||
"name": "next_eval_at",
|
||||
"type": "timestamp with time zone",
|
||||
+9
-2
@@ -2924,8 +2924,15 @@
|
||||
{
|
||||
"idx": 419,
|
||||
"version": "7",
|
||||
"when": 1793402408013,
|
||||
"tag": "0419_moaning_vulture",
|
||||
"when": 1793400383797,
|
||||
"tag": "0419_slippery_impossible_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 420,
|
||||
"version": "7",
|
||||
"when": 1793453735600,
|
||||
"tag": "0420_broad_johnny_storm",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -49,6 +49,7 @@ export const issueComments = pgTable(
|
||||
},
|
||||
(table) => ({
|
||||
clientRequestUq: unique("issue_comments_client_request_uq").on(table.issueId, table.authorUserId, table.clientRequestId),
|
||||
agentClientRequestUq: unique("issue_comments_agent_client_request_uq").on(table.issueId, table.authorAgentId, table.clientRequestId),
|
||||
companyIdUq: unique("issue_comments_company_id_uq").on(table.companyId, table.id),
|
||||
issueIdx: index("issue_comments_issue_idx").on(table.issueId),
|
||||
reviewResponseReceiptIdx: index("issue_comments_review_response_receipt_idx").on(
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
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";
|
||||
|
||||
const adminUrl = process.env.PAPERCLIP_TEST_POSTGRES_ADMIN_URL;
|
||||
const support = adminUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
||||
const journal = JSON.parse(await readFile(new URL("./migrations/meta/_journal.json", import.meta.url), "utf8")) as {
|
||||
entries: Array<{ idx: number; tag: string }>;
|
||||
};
|
||||
const master = journal.entries.find(entry => entry.tag === "0419_slippery_impossible_man")!;
|
||||
const status = journal.entries.find(entry => entry.tag === "0420_broad_johnny_storm")!;
|
||||
const statusSql = await readFile(new URL(`./migrations/${status.tag}.sql`, import.meta.url), "utf8");
|
||||
const hash = async (tag: string) => createHash("sha256")
|
||||
.update(await readFile(new URL(`./migrations/${tag}.sql`, import.meta.url), "utf8")).digest("hex");
|
||||
|
||||
async function database() {
|
||||
if (!adminUrl) return startEmbeddedPostgresTestDatabase("status-reason-upgrade-");
|
||||
const name = `status_reason_${randomUUID().replaceAll("-", "")}_test`;
|
||||
const admin = postgres(adminUrl, { max: 1, onnotice: () => {} });
|
||||
try { await admin.unsafe(`create database "${name}"`); }
|
||||
finally { await admin.end(); }
|
||||
const url = new URL(adminUrl); url.pathname = `/${name}`;
|
||||
const cleanup = async () => {
|
||||
const admin = postgres(adminUrl, { max: 1, onnotice: () => {} });
|
||||
try { await admin.unsafe(`drop database "${name}"`); }
|
||||
finally { await admin.end(); }
|
||||
};
|
||||
try { await applyPendingMigrations(url.toString()); }
|
||||
catch (error) { await cleanup(); throw error; }
|
||||
return { connectionString: url.toString(), cleanup };
|
||||
}
|
||||
|
||||
async function seed(sql: postgres.Sql) {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), userId = randomUUID();
|
||||
const lastMessage = new Date("2026-10-20T12:00:00.000Z");
|
||||
await sql`insert into companies (id, name, issue_prefix) values (${companyId}, 'Migration proof', ${`M${companyId.slice(0, 10)}`})`;
|
||||
await sql`insert into agents (id, company_id, name, adapter_type) values (${agentId}, ${companyId}, 'Agent', 'process')`;
|
||||
await sql`insert into "user" (id, name, email, email_verified, created_at, updated_at)
|
||||
values (${userId}, 'Reader', ${`${userId}@example.test`}, true, now(), now())`;
|
||||
await sql`insert into issues (id, company_id, title, status, assignee_agent_id,
|
||||
conversation_agent_id, conversation_user_id, conversation_state)
|
||||
values (${issueId}, ${companyId}, 'Conversation', 'in_review', ${agentId}, ${agentId}, ${userId}, 'waiting')`;
|
||||
await sql`insert into issue_comments (company_id, issue_id, author_agent_id, author_type, body, origin, client_request_id, created_at)
|
||||
values (${companyId}, ${issueId}, ${agentId}, 'agent', 'Preserved reply', 'comment', 'retry-key', ${lastMessage})`;
|
||||
await sql`insert into issue_comments (company_id, issue_id, author_agent_id, author_type, body, origin, created_at)
|
||||
values (${companyId}, ${issueId}, ${agentId}, 'system', 'Excluded system notice', 'comment', ${new Date(lastMessage.getTime() + 1000)})`;
|
||||
const cardId = randomUUID(), savedDeadline = new Date("2026-10-21T12:00:00.000Z");
|
||||
await sql`insert into status_cards (id, company_id, interest_prompt, refresh_policy, next_eval_at, pending_change_hash)
|
||||
values (${cardId}, ${companyId}, 'Watch preserved work', '{"mode":"manual"}'::jsonb, ${savedDeadline}, 'preserved-change')`;
|
||||
return { companyId, agentId, issueId, userId, lastMessage, cardId, savedDeadline };
|
||||
}
|
||||
|
||||
async function assertFinal(sql: postgres.Sql, row: Awaited<ReturnType<typeof seed>>, expectedRead: Date) {
|
||||
const [column] = await sql`select data_type, is_nullable from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'status_cards' and column_name = 'next_eval_reason'`;
|
||||
expect(column).toMatchObject({ data_type: "text", is_nullable: "YES" });
|
||||
const [constraint] = await sql`select i.indisvalid, i.indisunique from pg_constraint c
|
||||
join pg_index i on i.indexrelid = c.conindid
|
||||
where c.conrelid = 'issue_comments'::regclass and c.conname = 'issue_comments_agent_client_request_uq'`;
|
||||
expect(constraint).toMatchObject({ indisvalid: true, indisunique: true });
|
||||
const [read] = await sql`select last_read_at from issue_read_states
|
||||
where company_id = ${row.companyId} and issue_id = ${row.issueId} and user_id = ${row.userId}`;
|
||||
expect(read.last_read_at).toEqual(expectedRead);
|
||||
const [card] = await sql`select next_eval_at, next_eval_reason, pending_change_hash from status_cards where id = ${row.cardId}`;
|
||||
expect(card).toMatchObject({ next_eval_at: row.savedDeadline, next_eval_reason: null, pending_change_hash: "preserved-change" });
|
||||
await sql`update status_cards set next_eval_reason = 'retry' where id = ${row.cardId}`;
|
||||
expect((await sql`select next_eval_at, next_eval_reason from status_cards where id = ${row.cardId}`)[0])
|
||||
.toMatchObject({ next_eval_at: row.savedDeadline, next_eval_reason: "retry" });
|
||||
await expect(sql`insert into issue_comments (company_id, issue_id, author_agent_id, body, client_request_id)
|
||||
values (${row.companyId}, ${row.issueId}, ${row.agentId}, 'Duplicate', 'retry-key')`)
|
||||
.rejects.toMatchObject({ code: "23505", constraint_name: "issue_comments_agent_client_request_uq" });
|
||||
}
|
||||
|
||||
describe.skipIf(!support.supported)("status-card reason migration after the published comment migration", () => {
|
||||
it.each(["0418", "master0419"] as const)("upgrades from %s without dropping comment uniqueness or read history", async previous => {
|
||||
expect(master.idx).toBe(419);
|
||||
expect(status.idx).toBeGreaterThan(master.idx);
|
||||
expect(statusSql.trim()).toBe('ALTER TABLE "status_cards" ADD COLUMN "next_eval_reason" text;');
|
||||
const db = await database();
|
||||
const sql = postgres(db.connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
// Reconstruct exactly the two migrations' preimages; all older schema and
|
||||
// receipts stay intact, as in the repository's other replay regressions.
|
||||
await sql`alter table status_cards drop column next_eval_reason`;
|
||||
await sql`delete from drizzle.__drizzle_migrations where hash = ${await hash(status.tag)}`;
|
||||
if (previous === "0418") {
|
||||
await sql`alter table issue_comments drop constraint issue_comments_agent_client_request_uq`;
|
||||
await sql`delete from drizzle.__drizzle_migrations where hash = ${await hash(master.tag)}`;
|
||||
}
|
||||
const row = await seed(sql);
|
||||
const preservedRead = new Date("2026-10-19T12:00:00.000Z");
|
||||
if (previous === "master0419") await sql`insert into issue_read_states (company_id, issue_id, user_id, last_read_at)
|
||||
values (${row.companyId}, ${row.issueId}, ${row.userId}, ${preservedRead})`;
|
||||
expect(await inspectMigrations(db.connectionString)).toMatchObject({
|
||||
status: "needsMigrations",
|
||||
pendingMigrations: previous === "0418" ? [`${master.tag}.sql`, `${status.tag}.sql`] : [`${status.tag}.sql`],
|
||||
});
|
||||
await applyPendingMigrations(db.connectionString);
|
||||
expect(await inspectMigrations(db.connectionString)).toMatchObject({ status: "upToDate" });
|
||||
await assertFinal(sql, row, previous === "0418" ? row.lastMessage : preservedRead);
|
||||
const receipts = await sql`select hash from drizzle.__drizzle_migrations order by id`;
|
||||
const hashes = receipts.map(receipt => receipt.hash);
|
||||
expect(hashes.indexOf(await hash(master.tag))).toBeLessThan(hashes.indexOf(await hash(status.tag)));
|
||||
await applyPendingMigrations(db.connectionString);
|
||||
expect(await sql`select hash from drizzle.__drizzle_migrations order by id`).toEqual(receipts);
|
||||
} finally { await sql.end(); await db.cleanup(); }
|
||||
}, 90_000);
|
||||
});
|
||||
Reference in new issue
Block a user