diff --git a/package.json b/package.json index ee99c9e386..8f110d4d50 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,11 @@ "worktree:setup": "node scripts/worktree.mjs setup", "cloudflare:token": "node scripts/refresh-cloudflare-tunnel-token.mjs", "prepare": "simple-git-hooks", - "lint": "pnpm file-length:check && pnpm migration-metadata:check && pnpm test-architecture:check && pnpm dashboard-style:check && pnpm --filter @sentry/junior tool-annotations:check && pnpm --filter @sentry/junior tool-key-casing:check && pnpm --filter @sentry/junior tool-error-classification:check && pnpm --filter @sentry/junior lint && pnpm --filter @sentry/junior-memory lint && pnpm --filter @sentry/junior-github lint && pnpm --filter @sentry/junior-google lint && pnpm --filter @sentry/junior-linear lint && pnpm --filter @sentry/junior-sentry lint && pnpm --filter @sentry/junior-vercel lint && pnpm --filter @sentry/junior-dashboard lint && pnpm ast-grep:lint && pnpm package:lint", + "lint": "pnpm file-length:check && pnpm oxlint-rules:check && pnpm migration-metadata:check && pnpm test-architecture:check && pnpm dashboard-style:check && pnpm --filter @sentry/junior tool-annotations:check && pnpm --filter @sentry/junior tool-key-casing:check && pnpm --filter @sentry/junior tool-error-classification:check && pnpm --filter @sentry/junior lint && pnpm --filter @sentry/junior-memory lint && pnpm --filter @sentry/junior-github lint && pnpm --filter @sentry/junior-google lint && pnpm --filter @sentry/junior-linear lint && pnpm --filter @sentry/junior-sentry lint && pnpm --filter @sentry/junior-vercel lint && pnpm --filter @sentry/junior-dashboard lint && pnpm ast-grep:lint && pnpm package:lint", "lint:fix": "pnpm --filter @sentry/junior lint:fix", "file-length:check": "node --test scripts/check-file-length.test.mjs && node scripts/check-file-length.mjs", "migration-metadata:check": "node --test scripts/check-drizzle-migrations.test.mjs && node scripts/check-drizzle-migrations.mjs", + "oxlint-rules:check": "node --test tools/oxlint/junior/rules/*.test.ts", "test-architecture:check": "node --test scripts/check-test-architecture.test.mjs && node scripts/check-test-architecture.mjs", "dashboard-style:check": "node --test scripts/check-dashboard-styles.test.mjs && node scripts/check-dashboard-styles.mjs", "ast-grep:lint": "ast-grep test --test-dir ast-grep/tests --skip-snapshot-tests && ast-grep scan", diff --git a/packages/junior-dashboard/src/app.ts b/packages/junior-dashboard/src/app.ts index c21acc7c45..f8c4e142e8 100644 --- a/packages/junior-dashboard/src/app.ts +++ b/packages/junior-dashboard/src/app.ts @@ -508,7 +508,11 @@ export function createDashboardApp( }; app.use("*", requireAuth); - app.use("/api/conversations/:conversationId", revalidateConversation); + // Core detail reads set a version ETag before they build the report. Mock + // reporting has no stored version, so it revalidates on content instead. + if (options.mockConversations) { + app.use("/api/conversations/:conversationId", revalidateConversation); + } for (const route of authenticatedRoutes) { const handler = (c: Context<{ Variables: Variables }>) => { diff --git a/packages/junior-dashboard/src/conversation-cache.ts b/packages/junior-dashboard/src/conversation-cache.ts index cb091cd5b8..8852f52160 100644 --- a/packages/junior-dashboard/src/conversation-cache.ts +++ b/packages/junior-dashboard/src/conversation-cache.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import type { MiddlewareHandler } from "hono"; -/** Revalidate dashboard detail reads only after the route checks viewer access. */ +/** Revalidate mock detail reads on content, after the route checks viewer access. */ export const revalidateConversation: MiddlewareHandler = async ( context, next, diff --git a/packages/junior/.oxlintrc.json b/packages/junior/.oxlintrc.json index 746ee96651..47f40e3f10 100644 --- a/packages/junior/.oxlintrc.json +++ b/packages/junior/.oxlintrc.json @@ -13,6 +13,10 @@ { "name": "anti-slop", "specifier": "../../tools/oxlint/anti-slop/index.ts" + }, + { + "name": "junior", + "specifier": "../../tools/oxlint/junior/index.ts" } ], "ignorePatterns": [ @@ -28,7 +32,8 @@ ".pi/**", ".roo/**", ".windsurf/**", - "tools/oxlint/anti-slop/**" + "tools/oxlint/anti-slop/**", + "tools/oxlint/junior/**" ], "rules": { "no-restricted-imports": [ @@ -73,6 +78,12 @@ "anti-slop/require-safety-comment-for-type-assertion": "off" }, "overrides": [ + { + "files": ["src/**/*.ts"], + "rules": { + "junior/touch-conversation-detail": "error" + } + }, { "files": ["**/junior-github/src/**/*.ts"], "rules": { diff --git a/packages/junior/migrations/0075_conversation_detail_version.sql b/packages/junior/migrations/0075_conversation_detail_version.sql new file mode 100644 index 0000000000..cdf123f07d --- /dev/null +++ b/packages/junior/migrations/0075_conversation_detail_version.sql @@ -0,0 +1 @@ +ALTER TABLE "junior_conversations" ADD COLUMN "detail_version" integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/packages/junior/migrations/meta/0075_snapshot.json b/packages/junior/migrations/meta/0075_snapshot.json new file mode 100644 index 0000000000..963fea1b61 --- /dev/null +++ b/packages/junior/migrations/meta/0075_snapshot.json @@ -0,0 +1,4299 @@ +{ + "id": "b0120888-befa-4b42-b9cb-80f8275a4d94", + "prevId": "2e88a3fb-855b-4eef-927c-23647c6a70bf", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_bindings": { + "name": "junior_agent_bindings", + "schema": "", + "columns": { + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_agent_bindings_child_idx": { + "name": "junior_agent_bindings_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": [ + "parent_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": [ + "child_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_bindings_parent_conversation_id_name_pk": { + "name": "junior_agent_bindings_parent_conversation_id_name_pk", + "columns": [ + "parent_conversation_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_agent_invocations": { + "name": "junior_agent_invocations", + "schema": "", + "columns": { + "invocation_id": { + "name": "invocation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_context_json": { + "name": "credential_context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_visibility": { + "name": "destination_visibility", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_type": { + "name": "agent_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_profile": { + "name": "model_profile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_tool_call_id": { + "name": "parent_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mailbox_status": { + "name": "mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_mailbox_status": { + "name": "parent_mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_agent_invocations_child_idx": { + "name": "junior_agent_invocations_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_mailbox_idx": { + "name": "junior_agent_invocations_mailbox_idx", + "columns": [ + { + "expression": "mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_parent_mailbox_idx": { + "name": "junior_agent_invocations_parent_mailbox_idx", + "columns": [ + { + "expression": "parent_mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "terminal_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "parent_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "child_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_api_tokens": { + "name": "junior_api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_email_normalized": { + "name": "owner_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_suffix": { + "name": "token_suffix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_api_tokens_token_hash_uidx": { + "name": "junior_api_tokens_token_hash_uidx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_api_tokens_owner_email_idx": { + "name": "junior_api_tokens_owner_email_idx", + "columns": [ + { + "expression": "owner_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_artifacts": { + "name": "junior_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ext": { + "name": "ext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "delete_requested_at": { + "name": "delete_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_artifacts_conversation_sha_uidx": { + "name": "junior_artifacts_conversation_sha_uidx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_artifacts_gc_idx": { + "name": "junior_artifacts_gc_idx", + "columns": [ + { + "expression": "delete_requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_artifacts_conversation_idx": { + "name": "junior_artifacts_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_attachments": { + "name": "junior_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vision_summary": { + "name": "vision_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "delete_requested_at": { + "name": "delete_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_attachments_conversation_idx": { + "name": "junior_attachments_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_attachments_provider_idx": { + "name": "junior_attachments_provider_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_attachments_gc_idx": { + "name": "junior_attachments_gc_idx", + "columns": [ + { + "expression": "storage_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delete_requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_attachments_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_attachments_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_attachments", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_automation_runs": { + "name": "junior_automation_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "automation_version": { + "name": "automation_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scheduled_for_ms": { + "name": "scheduled_for_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_at_ms": { + "name": "claimed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "dispatch_id": { + "name": "dispatch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at_ms": { + "name": "started_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "completed_at_ms": { + "name": "completed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_message_ts": { + "name": "result_message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_automation_runs_automation_idx": { + "name": "junior_automation_runs_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automation_runs_completed_idx": { + "name": "junior_automation_runs_completed_idx", + "columns": [ + { + "expression": "completed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automation_runs_incomplete_idx": { + "name": "junior_automation_runs_incomplete_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automation_runs\".\"status\" in ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_automation_runs_automation_id_junior_automations_id_fk": { + "name": "junior_automation_runs_automation_id_junior_automations_id_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_automation_runs_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_automation_runs_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_automation_runs_automation_version_fk": { + "name": "junior_automation_runs_automation_version_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_automation_versions", + "columnsFrom": [ + "automation_id", + "automation_version" + ], + "columnsTo": [ + "automation_id", + "version" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "junior_automation_runs_conversation_id_unique": { + "name": "junior_automation_runs_conversation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "conversation_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "junior_automation_runs_status_check": { + "name": "junior_automation_runs_status_check", + "value": "\"junior_automation_runs\".\"status\" in ('pending', 'running', 'completed', 'failed', 'blocked', 'skipped')" + } + }, + "isRLSEnabled": false + }, + "public.junior_automation_versions": { + "name": "junior_automation_versions", + "schema": "", + "columns": { + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "edited_by": { + "name": "edited_by", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "definition": { + "name": "definition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_automation_versions_automation_id_fk": { + "name": "junior_automation_versions_automation_id_fk", + "tableFrom": "junior_automation_versions", + "tableTo": "junior_automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_automation_versions_pk": { + "name": "junior_automation_versions_pk", + "columns": [ + "automation_id", + "version" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_automations": { + "name": "junior_automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_slack_user_id": { + "name": "creator_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "next_run_at_ms": { + "name": "next_run_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "run_now_at_ms": { + "name": "run_now_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record": { + "name": "record", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_automations_team_idx": { + "name": "junior_automations_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_creator_identity_idx": { + "name": "junior_automations_creator_identity_idx", + "columns": [ + { + "expression": "creator_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_run_now_due_idx": { + "name": "junior_automations_run_now_due_idx", + "columns": [ + { + "expression": "run_now_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" = 'active' AND \"junior_automations\".\"run_now_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_next_run_due_idx": { + "name": "junior_automations_next_run_due_idx", + "columns": [ + { + "expression": "next_run_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" = 'active' AND \"junior_automations\".\"next_run_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_match_idx": { + "name": "junior_automations_match_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_automations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_automations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_automations", + "tableTo": "junior_identities", + "columnsFrom": [ + "creator_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_automations_kind_check": { + "name": "junior_automations_kind_check", + "value": "\"junior_automations\".\"kind\" in ('scheduled', 'event')" + }, + "junior_automations_status_check": { + "name": "junior_automations_status_check", + "value": "\"junior_automations\".\"status\" in ('active', 'blocked', 'paused', 'completed', 'deleted')" + }, + "junior_automations_record_check": { + "name": "junior_automations_record_check", + "value": "jsonb_typeof(\"junior_automations\".\"record\") = 'object'" + }, + "junior_automations_trigger_check": { + "name": "junior_automations_trigger_check", + "value": "(\"junior_automations\".\"kind\" = 'scheduled' and \"junior_automations\".\"namespace\" is null and \"junior_automations\".\"identifier\" is null) or (\"junior_automations\".\"kind\" = 'event' and \"junior_automations\".\"namespace\" is not null and \"junior_automations\".\"next_run_at_ms\" is null and \"junior_automations\".\"run_now_at_ms\" is null and \"junior_automations\".\"status\" <> 'completed')" + } + }, + "isRLSEnabled": false + }, + "public.junior_code_changes": { + "name": "junior_code_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "conversation_ids": { + "name": "conversation_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_code_changes_provider_id_idx": { + "name": "junior_code_changes_provider_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_opened_at_idx": { + "name": "junior_code_changes_opened_at_idx", + "columns": [ + { + "expression": "opened_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_merged_at_idx": { + "name": "junior_code_changes_merged_at_idx", + "columns": [ + { + "expression": "merged_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_closed_at_idx": { + "name": "junior_code_changes_closed_at_idx", + "columns": [ + { + "expression": "closed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_open_idx": { + "name": "junior_code_changes_open_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_code_changes\".\"state\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_code_changes_repository_id_junior_code_repositories_id_fk": { + "name": "junior_code_changes_repository_id_junior_code_repositories_id_fk", + "tableFrom": "junior_code_changes", + "tableTo": "junior_code_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_code_repositories": { + "name": "junior_code_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_code_repositories_provider_id_idx": { + "name": "junior_code_repositories_provider_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_annotations": { + "name": "junior_conversation_annotations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "annotation_json": { + "name": "annotation_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_conversation_annotations_conversation_id_fk": { + "name": "junior_conversation_annotations_conversation_id_fk", + "tableFrom": "junior_conversation_annotations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_annotations_pk": { + "name": "junior_conversation_annotations_pk", + "columns": [ + "conversation_id", + "plugin", + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_bindings": { + "name": "junior_conversation_bindings", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_conversation_id": { + "name": "provider_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_bindings_conversation_idx": { + "name": "junior_conversation_bindings_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_bindings", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_bindings_provider_conversation_pk": { + "name": "junior_conversation_bindings_provider_conversation_pk", + "columns": [ + "provider", + "provider_tenant_id", + "provider_destination_id", + "provider_conversation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_briefs": { + "name": "junior_conversation_briefs", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "through_seq": { + "name": "through_seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "search_text": { + "name": "search_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_usd": { + "name": "cost_usd", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "junior_conversation_briefs_conversation_turn_idx": { + "name": "junior_conversation_briefs_conversation_turn_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_briefs_conversation_version_idx": { + "name": "junior_conversation_briefs_conversation_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_briefs_search_idx": { + "name": "junior_conversation_briefs_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"search_text\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_briefs_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_briefs_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_briefs", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_briefs_conversation_id_version_pk": { + "name": "junior_conversation_briefs_conversation_id_version_pk", + "columns": [ + "conversation_id", + "version" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_events": { + "name": "junior_conversation_events", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "history_version": { + "name": "history_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_events_history_version_idx": { + "name": "junior_conversation_events_history_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "history_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_type_idx": { + "name": "junior_conversation_events_type_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_plugin_report_idx": { + "name": "junior_conversation_events_plugin_report_idx", + "columns": [ + { + "expression": "(\"payload\"->>'namespace')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "(\"payload\"->>'name')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversation_events\".\"type\" = 'structured_event'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_actor_identity_idx": { + "name": "junior_conversation_events_actor_identity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_message_search_idx": { + "name": "junior_conversation_events_message_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"payload\"->>'text')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversation_events\".\"type\" = 'message'", + "concurrently": false, + "method": "gin", + "with": {} + }, + "junior_conversation_events_idempotency_idx": { + "name": "junior_conversation_events_idempotency_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_events_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversation_events_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_identities", + "columnsFrom": [ + "actor_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_events_conversation_id_seq_pk": { + "name": "junior_conversation_events_conversation_id_seq_pk", + "columns": [ + "conversation_id", + "seq" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_metrics": { + "name": "junior_conversation_metrics", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_metrics_occurred_at_metric_idx": { + "name": "junior_conversation_metrics_occurred_at_metric_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_metrics_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_metrics_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_metrics", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_metrics_conversation_id_run_id_metric_pk": { + "name": "junior_conversation_metrics_conversation_id_run_id_metric_pk", + "columns": [ + "conversation_id", + "run_id", + "metric" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_participants": { + "name": "junior_conversation_participants", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversation_participants_user_activity_idx": { + "name": "junior_conversation_participants_user_activity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_participants_root_idx": { + "name": "junior_conversation_participants_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_participants_user_id_junior_users_id_fk": { + "name": "junior_conversation_participants_user_id_junior_users_id_fk", + "tableFrom": "junior_conversation_participants", + "tableTo": "junior_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_participants_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_participants_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_participants", + "tableTo": "junior_conversations", + "columnsFrom": [ + "root_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_participants_user_root_pk": { + "name": "junior_conversation_participants_user_root_pk", + "columns": [ + "user_id", + "root_conversation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_read_positions": { + "name": "junior_conversation_read_positions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_seq": { + "name": "last_read_seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_conversation_read_positions_user_id_junior_users_id_fk": { + "name": "junior_conversation_read_positions_user_id_junior_users_id_fk", + "tableFrom": "junior_conversation_read_positions", + "tableTo": "junior_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_read_positions_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_read_positions_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_read_positions", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_read_positions_user_conversation_pk": { + "name": "junior_conversation_read_positions_user_conversation_pk", + "columns": [ + "user_id", + "conversation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversations": { + "name": "junior_conversations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "location_json": { + "name": "location_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject_identity_id": { + "name": "credential_subject_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "execution_updated_at": { + "name": "execution_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_status": { + "name": "execution_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checkpoint_at": { + "name": "last_checkpoint_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forked_from_conversation_id": { + "name": "forked_from_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quick_question_at": { + "name": "quick_question_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_purged_at": { + "name": "transcript_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_duration_ms": { + "name": "execution_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "execution_usage_json": { + "name": "execution_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detail_version": { + "name": "detail_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversations_last_activity_idx": { + "name": "junior_conversations_last_activity_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_destination_activity_idx": { + "name": "junior_conversations_destination_activity_idx", + "columns": [ + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_actor_activity_idx": { + "name": "junior_conversations_actor_activity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_forked_from_idx": { + "name": "junior_conversations_forked_from_idx", + "columns": [ + { + "expression": "forked_from_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_root_idx": { + "name": "junior_conversations_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_destinations", + "columnsFrom": [ + "destination_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": [ + "actor_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": [ + "creator_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": [ + "credential_subject_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "parent_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_forked_from_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_forked_from_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "forked_from_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "root_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_destinations": { + "name": "junior_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_destination_id": { + "name": "parent_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_destinations_provider_destination_uidx": { + "name": "junior_destinations_provider_destination_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_destinations_provider_kind_idx": { + "name": "junior_destinations_provider_kind_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_identities": { + "name": "junior_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "signed_in_at": { + "name": "signed_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_identities_provider_subject_uidx": { + "name": "junior_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_kind_provider_idx": { + "name": "junior_identities_kind_provider_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "tableTo": "junior_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_location_configurations": { + "name": "junior_location_configurations", + "schema": "", + "columns": { + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "junior_location_configurations_location_id_junior_destinations_id_fk": { + "name": "junior_location_configurations_location_id_junior_destinations_id_fk", + "tableFrom": "junior_location_configurations", + "tableTo": "junior_destinations", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_location_configurations_location_id_key_pk": { + "name": "junior_location_configurations_location_id_key_pk", + "columns": [ + "location_id", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_review_item_assignees": { + "name": "junior_review_item_assignees", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_review_item_assignees_user_idx": { + "name": "junior_review_item_assignees_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_review_item_assignees_item_id_junior_review_items_id_fk": { + "name": "junior_review_item_assignees_item_id_junior_review_items_id_fk", + "tableFrom": "junior_review_item_assignees", + "tableTo": "junior_review_items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_review_item_assignees_user_id_junior_users_id_fk": { + "name": "junior_review_item_assignees_user_id_junior_users_id_fk", + "tableFrom": "junior_review_item_assignees", + "tableTo": "junior_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_review_item_assignees_item_id_user_id_pk": { + "name": "junior_review_item_assignees_item_id_user_id_pk", + "columns": [ + "item_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_review_item_conversations": { + "name": "junior_review_item_conversations", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_review_item_conversations_conversation_idx": { + "name": "junior_review_item_conversations_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_review_item_conversations_item_id_junior_review_items_id_fk": { + "name": "junior_review_item_conversations_item_id_junior_review_items_id_fk", + "tableFrom": "junior_review_item_conversations", + "tableTo": "junior_review_items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_review_item_conversations_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_review_item_conversations_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_review_item_conversations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_review_item_conversations_item_id_conversation_id_pk": { + "name": "junior_review_item_conversations_item_id_conversation_id_pk", + "columns": [ + "item_id", + "conversation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_review_item_slack_cards": { + "name": "junior_review_item_slack_cards", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_review_item_slack_cards_item_id_junior_review_items_id_fk": { + "name": "junior_review_item_slack_cards_item_id_junior_review_items_id_fk", + "tableFrom": "junior_review_item_slack_cards", + "tableTo": "junior_review_items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_review_item_slack_cards_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_review_item_slack_cards_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_review_item_slack_cards", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_review_item_slack_cards_item_id_conversation_id_pk": { + "name": "junior_review_item_slack_cards_item_id_conversation_id_pk", + "columns": [ + "item_id", + "conversation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_review_items": { + "name": "junior_review_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "answer": { + "name": "answer", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assigned_to_everyone": { + "name": "assigned_to_everyone", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject_title": { + "name": "subject_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject_url": { + "name": "subject_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by_conversation_id": { + "name": "added_by_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_turn_id": { + "name": "added_by_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_by_user_id": { + "name": "closed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_review_items_open_dedupe_uidx": { + "name": "junior_review_items_open_dedupe_uidx", + "columns": [ + { + "expression": "coalesce(\"provider\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"provider_tenant_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"junior_review_items\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_review_items_status_idx": { + "name": "junior_review_items_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_review_items_subject_idx": { + "name": "junior_review_items_subject_idx", + "columns": [ + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_review_items_automation_idx": { + "name": "junior_review_items_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_review_items_added_by_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_review_items_added_by_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_review_items", + "tableTo": "junior_conversations", + "columnsFrom": [ + "added_by_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_review_items_automation_id_junior_automations_id_fk": { + "name": "junior_review_items_automation_id_junior_automations_id_fk", + "tableFrom": "junior_review_items", + "tableTo": "junior_automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "junior_review_items_closed_by_user_id_junior_users_id_fk": { + "name": "junior_review_items_closed_by_user_id_junior_users_id_fk", + "tableFrom": "junior_review_items", + "tableTo": "junior_users", + "columnsFrom": [ + "closed_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_review_items_kind_check": { + "name": "junior_review_items_kind_check", + "value": "(\"junior_review_items\".\"kind\" = 'notice' and \"junior_review_items\".\"request\" is null and \"junior_review_items\".\"answer\" is null) or (\"junior_review_items\".\"kind\" = 'action' and \"junior_review_items\".\"request\" is not null)" + }, + "junior_review_items_status_check": { + "name": "junior_review_items_status_check", + "value": "(\"junior_review_items\".\"status\" = 'open' and \"junior_review_items\".\"closed_at\" is null) or (\"junior_review_items\".\"status\" in ('done', 'dismissed') and \"junior_review_items\".\"closed_at\" is not null)" + }, + "junior_review_items_tenant_check": { + "name": "junior_review_items_tenant_check", + "value": "(\"junior_review_items\".\"provider\" is null) = (\"junior_review_items\".\"provider_tenant_id\" is null)" + }, + "junior_review_items_visibility_check": { + "name": "junior_review_items_visibility_check", + "value": "\"junior_review_items\".\"visibility\" = 'public' or not \"junior_review_items\".\"assigned_to_everyone\"" + } + }, + "isRLSEnabled": false + }, + "public.junior_snapshots": { + "name": "junior_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_hash": { + "name": "profile_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_duration_ms": { + "name": "build_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "build_started_at": { + "name": "build_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "build_phase": { + "name": "build_phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_sandbox_name": { + "name": "build_sandbox_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_command_id": { + "name": "build_command_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_error": { + "name": "build_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_snapshots_snapshot_id_uidx": { + "name": "junior_snapshots_snapshot_id_uidx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_idx": { + "name": "junior_snapshots_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_profile_status_idx": { + "name": "junior_snapshots_workspace_profile_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_active_build_uidx": { + "name": "junior_snapshots_active_build_uidx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"junior_snapshots\".\"status\" = 'building'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_status_idx": { + "name": "junior_snapshots_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_snapshots_workspace_id_junior_workspaces_id_fk": { + "name": "junior_snapshots_workspace_id_junior_workspaces_id_fk", + "tableFrom": "junior_snapshots", + "tableTo": "junior_workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_snapshots_status_check": { + "name": "junior_snapshots_status_check", + "value": "\"junior_snapshots\".\"status\" in ('building', 'failed', 'ready')" + }, + "junior_snapshots_build_phase_check": { + "name": "junior_snapshots_build_phase_check", + "value": "\"junior_snapshots\".\"build_phase\" is null or \"junior_snapshots\".\"build_phase\" in ('created', 'dependencies_installed', 'repositories_prepared')" + }, + "junior_snapshots_ready_fields_check": { + "name": "junior_snapshots_ready_fields_check", + "value": "\"junior_snapshots\".\"status\" <> 'ready' or (\"junior_snapshots\".\"snapshot_id\" is not null and \"junior_snapshots\".\"build_duration_ms\" is not null and \"junior_snapshots\".\"generated_at\" is not null)" + }, + "junior_snapshots_build_fields_check": { + "name": "junior_snapshots_build_fields_check", + "value": "\"junior_snapshots\".\"status\" = 'ready' or (\"junior_snapshots\".\"build_started_at\" is not null and \"junior_snapshots\".\"build_phase\" is not null)" + }, + "junior_snapshots_build_duration_check": { + "name": "junior_snapshots_build_duration_check", + "value": "\"junior_snapshots\".\"build_duration_ms\" is null or \"junior_snapshots\".\"build_duration_ms\" >= 0" + }, + "junior_snapshots_size_bytes_check": { + "name": "junior_snapshots_size_bytes_check", + "value": "\"junior_snapshots\".\"size_bytes\" is null or \"junior_snapshots\".\"size_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.junior_stats": { + "name": "junior_stats", + "schema": "", + "columns": { + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "junior_stats_date_namespace_metric_name_pk": { + "name": "junior_stats_date_namespace_metric_name_pk", + "columns": [ + "date", + "namespace", + "metric", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_users": { + "name": "junior_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email_normalized": { + "name": "primary_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customization": { + "name": "customization", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_users_primary_email_normalized_uidx": { + "name": "junior_users_primary_email_normalized_uidx", + "columns": [ + { + "expression": "primary_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_workspace_repos": { + "name": "junior_workspace_repos", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_workspace_repos_workspace_id_junior_workspaces_id_fk": { + "name": "junior_workspace_repos_workspace_id_junior_workspaces_id_fk", + "tableFrom": "junior_workspace_repos", + "tableTo": "junior_workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_workspace_repos_workspace_id_provider_repo_pk": { + "name": "junior_workspace_repos_workspace_id_provider_repo_pk", + "columns": [ + "workspace_id", + "provider", + "repo" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_workspaces": { + "name": "junior_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setup_script": { + "name": "setup_script", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "vcpus": { + "name": "vcpus", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_workspaces_name_idx": { + "name": "junior_workspaces_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index 8f86642a4c..226ccbae38 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -526,6 +526,13 @@ "when": 1793803896566, "tag": "0074_conversation_participant_pins", "breakpoints": true + }, + { + "idx": 75, + "version": "7", + "when": 1793810669602, + "tag": "0075_conversation_detail_version", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/junior/src/api/conversations/archive.ts b/packages/junior/src/api/conversations/archive.ts index b56b0bdf61..f823329227 100644 --- a/packages/junior/src/api/conversations/archive.ts +++ b/packages/junior/src/api/conversations/archive.ts @@ -1,6 +1,7 @@ import type { User } from "@sentry/junior-plugin-api"; import { and, eq, isNotNull, sql } from "drizzle-orm"; import { getDb, getSqlExecutor } from "@/chat/db"; +import { conversationDetailTouch } from "@/chat/conversations/sql/detail-version"; import { resolveRootConversationId } from "@/chat/conversations/sql/participants"; import { juniorConversationParticipants, @@ -40,6 +41,7 @@ async function setViewerArchive(args: { const archivedAt = new Date(); const lastMessageAt = new Date(args.lastSeenAt); await db + .with(conversationDetailTouch(db, [args.rootConversationId])) .insert(juniorConversationParticipants) .values({ archivedAt, @@ -66,6 +68,7 @@ async function setViewerArchive(args: { } await db + .with(conversationDetailTouch(db, [args.rootConversationId])) .update(juniorConversationParticipants) .set({ archivedAt: null }) .where( diff --git a/packages/junior/src/api/conversations/detail-etag.ts b/packages/junior/src/api/conversations/detail-etag.ts new file mode 100644 index 0000000000..f0c18aadb5 --- /dev/null +++ b/packages/junior/src/api/conversations/detail-etag.ts @@ -0,0 +1,86 @@ +import { createHash } from "node:crypto"; +import type { User } from "@sentry/junior-plugin-api"; +import { eq, sql } from "drizzle-orm"; +import { getDb } from "@/chat/db"; +import { + conversationSubtree, + conversationSubtreeColumns, +} from "@/chat/conversations/sql/subtree"; +import { juniorConversations } from "@/db/schema"; +import { JUNIOR_VERSION } from "@/version"; +import { readConversationAccessFromSql } from "./access"; +import { readConversationAgentTree } from "./agent-tree"; +import { readConversationForks } from "./fork"; +import { readConversationViewerMarks } from "./membership"; +import { readConversationReadPosition } from "./read-position"; + +/** + * Sum the detail versions of a Conversation and its child agents. Child + * writes bump only their own row, and the row count changes when a child + * starts, so the parent needs no bump of its own. + */ +async function readSubtreeDetailVersion( + conversationId: string, +): Promise<{ conversations: number; version: string }> { + const [row] = await getDb() + .select({ + conversations: sql`count(*)::int`, + version: sql`coalesce(sum(${juniorConversations.detailVersion}), 0)::text`, + }) + .from(juniorConversations) + .innerJoin( + conversationSubtree([conversationId]), + eq( + conversationSubtreeColumns.conversationId, + juniorConversations.conversationId, + ), + ); + return row ?? { conversations: 0, version: "0" }; +} + +/** + * Return the detail ETag without building the report, or undefined for a + * missing Conversation. + * + * Shared content is covered by `detail_version`. Viewer state and data that + * other Conversations own (forks, agent tree labels) are cheap, so they are + * read on each request. Names, avatars, Automation titles, and plugin sidebar + * labels are not covered: they refresh with the next detail change or a reload. + */ +export async function readConversationDetailEtag( + conversationId: string, + options: { limit: number; viewer?: User }, +): Promise { + const { viewer } = options; + const [subtree, access, viewerMarks, forks, agentTree, readPosition] = + await Promise.all([ + readSubtreeDetailVersion(conversationId), + readConversationAccessFromSql(getDb(), [conversationId], viewer), + viewer ? readConversationViewerMarks(viewer, conversationId) : undefined, + readConversationForks(conversationId, viewer), + readConversationAgentTree(conversationId, viewer), + viewer ? readConversationReadPosition(viewer, conversationId) : undefined, + ]); + if (subtree.conversations === 0) return undefined; + const basis = JSON.stringify({ + access: access.get(conversationId) ?? null, + agentTree: agentTree ?? null, + forks, + junior: JUNIOR_VERSION, + limit: options.limit, + readPosition: readPosition ?? null, + subtree, + viewerMarks: viewerMarks ?? null, + }); + return `W/"${createHash("sha256").update(basis).digest("hex")}"`; +} + +/** Report whether an `If-None-Match` header names the current ETag. */ +export function etagMatches(header: string | undefined, etag: string): boolean { + const opaque = etag.replace(/^W\//, ""); + return Boolean( + header + ?.split(",") + .some((value) => value.trim().replace(/^W\//, "") === opaque), + ); +} diff --git a/packages/junior/src/api/conversations/pin.ts b/packages/junior/src/api/conversations/pin.ts index 9d129d13fe..f2d3f85372 100644 --- a/packages/junior/src/api/conversations/pin.ts +++ b/packages/junior/src/api/conversations/pin.ts @@ -41,7 +41,10 @@ export async function pinConversation( conversationId, ); const db = executor.db(); + // Pins are viewer marks, which the detail ETag reads on each request, so + // they need no detail version bump. if (!body.pinned) { + // oxlint-disable-next-line junior/touch-conversation-detail -- viewer marks are read on each request. await db .update(juniorConversationParticipants) .set({ pinnedAt: null }) @@ -64,6 +67,7 @@ export async function pinConversation( .limit(1); if (!root) throwApiError(404, "Conversation not found."); const pinnedAt = new Date(); + // oxlint-disable-next-line junior/touch-conversation-detail -- viewer marks are read on each request. await db .insert(juniorConversationParticipants) .values({ diff --git a/packages/junior/src/api/conversations/routes.ts b/packages/junior/src/api/conversations/routes.ts index 58d0265600..f37495a43b 100644 --- a/packages/junior/src/api/conversations/routes.ts +++ b/packages/junior/src/api/conversations/routes.ts @@ -48,6 +48,7 @@ import { type WebInputServices, } from "./create"; import { readConversationDetail } from "./detail"; +import { etagMatches, readConversationDetailEtag } from "./detail-etag"; import { forkConversationForViewer, keepQuickQuestionForViewer } from "./fork"; import { readConversationEvents } from "./event-list"; import { readConversationFeed } from "./list"; @@ -454,12 +455,30 @@ export function createConversationRoutes( const { conversationId } = context.req.valid("param"); const query = context.req.valid("query"); const viewer = context.get("viewer"); + // Polls send the last ETag. Compare it before the report is built, + // because building the report is the expensive part of this route. + const etag = await readConversationDetailEtag(conversationId, { + limit: query.limit, + ...(viewer ? { viewer } : undefined), + }); + if (etag && etagMatches(context.req.header("if-none-match"), etag)) { + return new Response(null, { + status: 304, + headers: { "cache-control": "private, no-store", etag }, + }); + } const report = await readConversationDetail(conversationId, { ...query, ...(viewer ? { viewer } : undefined), }); if (!report) throwApiError(404, "Conversation not found."); - return jsonResponse(conversationDetailReportSchema, report); + return jsonResponse( + conversationDetailReportSchema, + report, + etag + ? { headers: { "cache-control": "private, no-store", etag } } + : undefined, + ); }, ); diff --git a/packages/junior/src/chat/attachments/store.ts b/packages/junior/src/chat/attachments/store.ts index 393eda56fa..0ae9f8f2fd 100644 --- a/packages/junior/src/chat/attachments/store.ts +++ b/packages/junior/src/chat/attachments/store.ts @@ -3,6 +3,10 @@ import { and, asc, eq, inArray, isNotNull, isNull, lte, or } from "drizzle-orm"; import type { JuniorSqlDatabase } from "@/db/db"; import { juniorAttachments, juniorConversations } from "@/db/schema"; import type { SandboxFileUpload } from "@/chat/tools/sandbox/file-uploads"; +import { + conversationDetailTouch, + touchConversationDetail, +} from "@/chat/conversations/sql/detail-version"; import type { AttachmentStorage } from "./storage"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -121,8 +125,9 @@ async function rejectPurgedAttachmentWrite(args: { storage: AttachmentStorage; storageKey: string; }): Promise { - await args.db - .db() + const db = args.db.db(); + await db + .with(conversationDetailTouch(db, [args.conversationId])) .update(juniorAttachments) .set({ deleteRequestedAt: args.now }) .where(eq(juniorAttachments.id, args.id)); @@ -235,6 +240,7 @@ export async function storeAttachment(args: { const oldStorageKey = existing.storageKey; const revived = await args.db .db() + .with(conversationDetailTouch(args.db.db(), [args.conversationId])) .update(juniorAttachments) .set({ deleteRequestedAt: null, @@ -287,6 +293,7 @@ export async function storeAttachment(args: { try { inserted = await args.db .db() + .with(conversationDetailTouch(args.db.db(), [args.conversationId])) .insert(juniorAttachments) .values({ id, @@ -354,6 +361,7 @@ export async function storeAttachment(args: { const revived = await args.db .db() + .with(conversationDetailTouch(args.db.db(), [args.conversationId])) .update(juniorAttachments) .set({ deleteRequestedAt: null, @@ -478,6 +486,7 @@ export async function requestAttachmentDeletion( if (conversationIds.length === 0) return; await db .db() + .with(conversationDetailTouch(db.db(), conversationIds)) .update(juniorAttachments) .set({ deleteRequestedAt: new Date(nowMs) }) .where( @@ -561,7 +570,11 @@ export async function collectAttachmentGarbage(args: { ), ) .returning({ - id: juniorAttachments.id, + conversationId: juniorAttachments.conversationId, }); + await touchConversationDetail( + args.db.db(), + removed.map((row) => row.conversationId), + ); return { deleted: removed.length }; } diff --git a/packages/junior/src/chat/briefs/store.ts b/packages/junior/src/chat/briefs/store.ts index b89034079d..13ef640189 100644 --- a/packages/junior/src/chat/briefs/store.ts +++ b/packages/junior/src/chat/briefs/store.ts @@ -1,6 +1,7 @@ import { and, desc, eq, max } from "drizzle-orm"; import type { JuniorDatabase } from "@/db/db"; import { juniorConversationBriefs, juniorConversations } from "@/db/schema"; +import { conversationDetailTouch } from "@/chat/conversations/sql/detail-version"; import { resolveRootVisibility } from "@/chat/conversations/sql/privacy"; import { conversationBriefSchema, type ConversationBrief } from "./schema"; @@ -92,6 +93,7 @@ export async function appendConversationBrief( .where(eq(juniorConversationBriefs.conversationId, input.conversationId)); const version = (versions[0]?.version ?? 0) + 1; const inserted = await tx + .with(conversationDetailTouch(tx, [input.conversationId])) .insert(juniorConversationBriefs) .values({ conversationId: input.conversationId, diff --git a/packages/junior/src/chat/conversations/fork.ts b/packages/junior/src/chat/conversations/fork.ts index 5af4c7e40b..ee2f2624e4 100644 --- a/packages/junior/src/chat/conversations/fork.ts +++ b/packages/junior/src/chat/conversations/fork.ts @@ -4,6 +4,7 @@ import type { User } from "@sentry/junior-plugin-api"; import type { ConversationEvent } from "./history"; import { loadTurnProjection } from "./projection"; import { contextProvenance } from "./provenance"; +import { nextDetailVersion } from "./sql/detail-version"; import { withConversationEventLock } from "./sql/event-lock"; import { createConversationId, @@ -185,6 +186,7 @@ export async function forkConversation(input: { ...(input.quickQuestion ? { quickQuestionAt: new Date(nowMs) } : undefined), + detailVersion: nextDetailVersion, }) .where(eq(juniorConversations.conversationId, conversationId)); await copyForkEvents({ @@ -228,7 +230,7 @@ export async function keepQuickQuestion(conversationId: string): Promise { const kept = await executor .db() .update(juniorConversations) - .set({ quickQuestionAt: null }) + .set({ quickQuestionAt: null, detailVersion: nextDetailVersion }) .where( and( eq(juniorConversations.conversationId, conversationId), diff --git a/packages/junior/src/chat/conversations/metrics.ts b/packages/junior/src/chat/conversations/metrics.ts index ff4fe3dae5..f3b6f85f4e 100644 --- a/packages/junior/src/chat/conversations/metrics.ts +++ b/packages/junior/src/chat/conversations/metrics.ts @@ -11,6 +11,7 @@ import { type AgentTurnUsage, } from "@/chat/usage"; import type { ConversationRunMetrics } from "./store"; +import { nextDetailVersion } from "./sql/detail-version"; function finite(value: number | undefined): number | undefined { return value !== undefined && Number.isFinite(value) && value >= 0 @@ -187,6 +188,7 @@ export async function replaceConversationMetrics( 'cost', ${cost} )) end`, + detailVersion: nextDetailVersion, }) .where(eq(juniorConversations.conversationId, args.conversationId)); } diff --git a/packages/junior/src/chat/conversations/session-stop.ts b/packages/junior/src/chat/conversations/session-stop.ts index 10d643c0b2..bdfef0b06a 100644 --- a/packages/junior/src/chat/conversations/session-stop.ts +++ b/packages/junior/src/chat/conversations/session-stop.ts @@ -6,6 +6,7 @@ import type { ConversationWorkerResult, } from "@/chat/task-execution/worker"; import { juniorConversations } from "@/db/schema"; +import { nextDetailVersion } from "@/chat/conversations/sql/detail-version"; /** * Record a session stop on each Conversation. Call this after their watches @@ -22,7 +23,7 @@ export async function markConversationsStopped( await getSqlExecutor() .db() .update(juniorConversations) - .set({ stoppedAt: new Date(nowMs) }) + .set({ stoppedAt: new Date(nowMs), detailVersion: nextDetailVersion }) .where(inArray(juniorConversations.conversationId, [...conversationIds])); } diff --git a/packages/junior/src/chat/conversations/sql/conversation-row.ts b/packages/junior/src/chat/conversations/sql/conversation-row.ts index 5c49e96fe2..f36c6a8359 100644 --- a/packages/junior/src/chat/conversations/sql/conversation-row.ts +++ b/packages/junior/src/chat/conversations/sql/conversation-row.ts @@ -1,6 +1,7 @@ import { sql } from "drizzle-orm"; import type { JuniorSqlDatabase } from "@/db/db"; import { juniorConversations } from "@/db/schema"; +import { nextDetailVersion } from "./detail-version"; /** * Establish the conversation metadata row on first contact for content writes @@ -41,6 +42,7 @@ export async function ensureConversationRow( lastActivityAt: sql`greatest(${juniorConversations.lastActivityAt}, excluded.last_activity_at)`, updatedAt: sql`greatest(${juniorConversations.updatedAt}, excluded.updated_at)`, transcriptPurgedAt: null, + detailVersion: nextDetailVersion, }, }); } diff --git a/packages/junior/src/chat/conversations/sql/detail-version.ts b/packages/junior/src/chat/conversations/sql/detail-version.ts new file mode 100644 index 0000000000..7df8a95dd4 --- /dev/null +++ b/packages/junior/src/chat/conversations/sql/detail-version.ts @@ -0,0 +1,48 @@ +import { inArray, sql } from "drizzle-orm"; +import type { JuniorDatabase } from "@/db/db"; +import { juniorConversations } from "@/db/schema"; + +/** SQL that advances `detail_version` inside a `juniorConversations` set. */ +export const nextDetailVersion = sql`${juniorConversations.detailVersion} + 1`; + +function bumpDetailVersion( + db: JuniorDatabase, + conversationIds: readonly string[], +) { + return db + .update(juniorConversations) + .set({ detailVersion: nextDetailVersion }) + .where(inArray(juniorConversations.conversationId, [...conversationIds])); +} + +/** + * Return a CTE that marks the detail report of each Conversation as changed, + * in the same statement as the write that it covers: + * `db.with(conversationDetailTouch(db, ids)).insert(table)`. + * Postgres runs a data-modifying CTE even when the statement does not read it, + * and both changes commit together. + */ +export function conversationDetailTouch( + db: JuniorDatabase, + conversationIds: readonly string[], +) { + return db.$with("conversation_detail_touch").as( + bumpDetailVersion(db, conversationIds).returning({ + conversationId: juniorConversations.conversationId, + }), + ); +} + +/** + * Mark the detail report of each Conversation as changed, for a write that + * cannot carry `conversationDetailTouch`. Call it after that write, in the same + * transaction when there is one. A bump before the write lets a detail read + * store old content under the new version. + */ +export async function touchConversationDetail( + db: JuniorDatabase, + conversationIds: readonly string[], +): Promise { + if (conversationIds.length === 0) return; + await bumpDetailVersion(db, conversationIds); +} diff --git a/packages/junior/src/chat/conversations/sql/history.ts b/packages/junior/src/chat/conversations/sql/history.ts index 315aeac29c..c7be02a869 100644 --- a/packages/junior/src/chat/conversations/sql/history.ts +++ b/packages/junior/src/chat/conversations/sql/history.ts @@ -35,6 +35,7 @@ import { import { juniorConversationEvents } from "@/db/schema"; import { sanitizePostgresJson } from "@/db/postgres-json"; import { encodeHistoryPayload } from "../history-payload"; +import { conversationDetailTouch } from "./detail-version"; import { withConversationEventLock } from "./event-lock"; import { recordConversationParticipant } from "./participants"; @@ -241,7 +242,11 @@ class SqlConversationEventStore implements ConversationEventStore { ), ); } - await this.executor.db().insert(juniorConversationEvents).values(rows); + const db = this.executor.db(); + await db + .with(conversationDetailTouch(db, [conversationId])) + .insert(juniorConversationEvents) + .values(rows); for (const event of pending) { if (event.data.type === "turn_started") { await this.recordTurnInputParticipants( @@ -318,8 +323,9 @@ class SqlConversationEventStore implements ConversationEventStore { } await ensureConversationRow(this.executor, conversationId, Date.now()); const historyVersion = currentHistoryVersion + 1; - await this.executor - .db() + const db = this.executor.db(); + await db + .with(conversationDetailTouch(db, [conversationId])) .insert(juniorConversationEvents) .values( insertFromEvent( diff --git a/packages/junior/src/chat/conversations/sql/location.ts b/packages/junior/src/chat/conversations/sql/location.ts index 0bd161bfcf..8b843f0abb 100644 --- a/packages/junior/src/chat/conversations/sql/location.ts +++ b/packages/junior/src/chat/conversations/sql/location.ts @@ -6,6 +6,7 @@ import type { SessionSource } from "@/chat/source"; import type { JuniorSqlDatabase } from "@/db/db"; import { juniorConversations, type juniorDestinations } from "@/db/schema"; import type { ProviderConversationBinding } from "./bindings"; +import { nextDetailVersion } from "./detail-version"; type LocationRow = typeof juniorDestinations.$inferSelect; @@ -131,6 +132,7 @@ export async function startLocationThread( .update(juniorConversations) .set({ location: sql`${location} || jsonb_build_object('threadTs', ${binding.providerConversationId}::text)`, + detailVersion: nextDetailVersion, }) .where( and( diff --git a/packages/junior/src/chat/conversations/sql/participants.ts b/packages/junior/src/chat/conversations/sql/participants.ts index ea08db63b2..539fdd31f0 100644 --- a/packages/junior/src/chat/conversations/sql/participants.ts +++ b/packages/junior/src/chat/conversations/sql/participants.ts @@ -53,6 +53,10 @@ export async function recordConversationParticipant( args.conversationId, ); const at = new Date(args.atMs); + // Each caller bumps the Conversation in the same transaction: event appends + // carry the touch, and the root upsert sets `detailVersion`. The root needs + // no bump, because its detail version sums its subtree. + // oxlint-disable-next-line junior/touch-conversation-detail -- callers bump in the same transaction. await executor .db() .insert(juniorConversationParticipants) diff --git a/packages/junior/src/chat/conversations/sql/purge.ts b/packages/junior/src/chat/conversations/sql/purge.ts index c3d31181dc..fa0069a631 100644 --- a/packages/junior/src/chat/conversations/sql/purge.ts +++ b/packages/junior/src/chat/conversations/sql/purge.ts @@ -12,6 +12,7 @@ import { juniorAgentInvocations, juniorReviewItems, } from "@/db/schema"; +import { touchConversationDetail } from "./detail-version"; import { withConversationEventLock } from "./event-lock"; import { resolveRootVisibility } from "./privacy"; import { withConversationMutationLock } from "./store"; @@ -360,6 +361,7 @@ export async function purgeConversationTree( : undefined), }) .where(inArray(juniorConversations.conversationId, ids)); + await touchConversationDetail(executor.db(), ids); return { purged: true, conversations: ids.length }; }, ); diff --git a/packages/junior/src/chat/conversations/sql/store.ts b/packages/junior/src/chat/conversations/sql/store.ts index bbd9928c07..94e6fc65ee 100644 --- a/packages/junior/src/chat/conversations/sql/store.ts +++ b/packages/junior/src/chat/conversations/sql/store.ts @@ -50,6 +50,7 @@ import { locationFromRow, visibilityFromLocationRow, } from "./location"; +import { nextDetailVersion } from "./detail-version"; type ConversationRow = Omit< typeof juniorConversations.$inferSelect, "legacyArchivedAt" @@ -573,6 +574,7 @@ export class SqlStore implements ConversationStore { metricRunId: args.execution.runId, executionDurationMs: args.metrics.durationMs, executionUsage: args.metrics.usage ?? null, + detailVersion: nextDetailVersion, }) .where(eq(juniorConversations.conversationId, args.conversationId)); } @@ -851,6 +853,7 @@ export class SqlStore implements ConversationStore { lastCheckpointAt: sql`case when ${incomingExecutionIsFresh} then coalesce(excluded.last_checkpoint_at, ${juniorConversations.lastCheckpointAt}) else ${juniorConversations.lastCheckpointAt} end`, lastEnqueuedAt: sql`case when ${incomingExecutionIsFresh} then coalesce(excluded.last_enqueued_at, ${juniorConversations.lastEnqueuedAt}) else ${juniorConversations.lastEnqueuedAt} end`, rootConversationId: sql`coalesce(${juniorConversations.rootConversationId}, excluded.root_conversation_id)`, + detailVersion: nextDetailVersion, }, }) .returning({ diff --git a/packages/junior/src/chat/plugins/annotations.ts b/packages/junior/src/chat/plugins/annotations.ts index 3e464af1e3..b5cbe35d03 100644 --- a/packages/junior/src/chat/plugins/annotations.ts +++ b/packages/junior/src/chat/plugins/annotations.ts @@ -9,6 +9,7 @@ import { juniorConversationAnnotations, juniorConversations, } from "@/db/schema"; +import { conversationDetailTouch } from "@/chat/conversations/sql/detail-version"; function annotationFromRow(row: { annotation: unknown; @@ -37,6 +38,7 @@ export function createPluginAnnotations(args: { const annotation = conversationAnnotationInputSchema.parse(input); const now = new Date(); await args.db + .with(conversationDetailTouch(args.db, [args.conversationId])) .insert(juniorConversationAnnotations) .values({ annotation, @@ -59,6 +61,7 @@ export function createPluginAnnotations(args: { }, async remove(kind, key) { await args.db + .with(conversationDetailTouch(args.db, [args.conversationId])) .delete(juniorConversationAnnotations) .where( and( diff --git a/packages/junior/src/chat/slack/attachments.ts b/packages/junior/src/chat/slack/attachments.ts index cb198ca3a0..4f641dd2dd 100644 --- a/packages/junior/src/chat/slack/attachments.ts +++ b/packages/junior/src/chat/slack/attachments.ts @@ -3,6 +3,7 @@ import type { MessageAttachment } from "@/chat/attachments/input"; import { and, eq, inArray, isNull } from "drizzle-orm"; import type { JuniorSqlDatabase } from "@/db/db"; import { juniorAttachments } from "@/db/schema"; +import { conversationDetailTouch } from "@/chat/conversations/sql/detail-version"; /** Save the vision summary for a stored Slack attachment. */ export async function recordSlackAttachment(args: { @@ -11,8 +12,9 @@ export async function recordSlackAttachment(args: { db: JuniorSqlDatabase; visionSummary: string; }): Promise { - await args.db - .db() + const db = args.db.db(); + await db + .with(conversationDetailTouch(db, [args.conversationId])) .update(juniorAttachments) .set({ visionSummary: args.visionSummary }) .where( diff --git a/packages/junior/src/db/schema/conversations.ts b/packages/junior/src/db/schema/conversations.ts index d2b223bd30..cb9550d0b4 100644 --- a/packages/junior/src/db/schema/conversations.ts +++ b/packages/junior/src/db/schema/conversations.ts @@ -89,6 +89,10 @@ export const juniorConversations = pgTable( executionDurationMs: integer("execution_duration_ms").notNull().default(0), executionUsage: jsonb("execution_usage_json").$type(), metricRunId: text("metric_run_id"), + // Bumped by every write that changes this Conversation's detail report. + // Detail reads compare it before they build the report. The + // `junior/touch-conversation-detail` lint rule enforces the bump. + detailVersion: integer("detail_version").notNull().default(0), // Keep the legacy column in the schema. Runtime code must not read or write it. legacyArchivedAt: timestamptz("archived_at"), }, diff --git a/packages/junior/tests/integration/api/conversations/detail.test.ts b/packages/junior/tests/integration/api/conversations/detail.test.ts index bd2d2a42d4..e556451bfb 100644 --- a/packages/junior/tests/integration/api/conversations/detail.test.ts +++ b/packages/junior/tests/integration/api/conversations/detail.test.ts @@ -17,6 +17,7 @@ import { usersInfoOk } from "../../../fixtures/slack/factories/api"; import { juniorIdentities } from "@/db/schema"; import { and, eq } from "drizzle-orm"; import { queueSlackApiResponse } from "../../../msw/handlers/slack-api"; +import { createChild } from "../../../fixtures/dashboard-reporting"; describe("conversation detail API", () => { afterEach(async () => { @@ -119,6 +120,69 @@ describe("conversation detail API", () => { }); }); + it("answers unchanged polls with 304 until the subtree or annotations change", async () => { + const conversationId = "local:test:revalidated-detail"; + const childConversationId = "local:test:revalidated-detail:child"; + await getConversationStore().recordActivity({ + destination: { platform: "local" as const, conversationId }, + conversationId, + nowMs: 1, + source: "internal", + title: "Revalidated conversation", + visibility: "public", + }); + const app = createJuniorApi(); + const poll = (etag?: string) => + app.request(`http://localhost/api/conversations/${conversationId}`, { + headers: etag ? { "if-none-match": etag } : {}, + }); + + const first = await poll(); + const firstEtag = first.headers.get("etag")!; + expect(first.status).toBe(200); + expect(firstEtag).toMatch(/^W\/"/); + const unchanged = await poll(firstEtag); + expect(unchanged.status).toBe(304); + expect(await unchanged.text()).toBe(""); + + await createChild({ + childConversationId, + parentConversationId: conversationId, + }); + const withChild = await poll(firstEtag); + const childEtag = withChild.headers.get("etag")!; + expect(withChild.status).toBe(200); + expect(childEtag).not.toBe(firstEtag); + + // A child agent writes only its own row. The parent still revalidates. + await getConversationEventStore().append(childConversationId, [ + { + createdAtMs: 5, + data: { + messageId: "child-message", + role: "assistant", + text: "child-message", + type: "message", + }, + }, + ]); + const childChanged = await poll(childEtag); + const childChangedEtag = childChanged.headers.get("etag")!; + expect(childChanged.status).toBe(200); + + await createPluginAnnotations({ + conversationId, + db: getDb(), + plugin: "github", + }).upsert({ + kind: "resource_link", + key: "getsentry/junior#1", + label: "getsentry/junior #1", + url: "https://github.com/getsentry/junior/pull/1", + }); + expect((await poll(childChangedEtag)).status).toBe(200); + }); + it("titles an untitled conversation from its first human message", async () => { const conversationId = "local:test:untitled-detail"; await getConversationStore().recordActivity({ diff --git a/packages/junior/tests/integration/api/conversations/pin.test.ts b/packages/junior/tests/integration/api/conversations/pin.test.ts index d71c0a15a1..292b033f0b 100644 --- a/packages/junior/tests/integration/api/conversations/pin.test.ts +++ b/packages/junior/tests/integration/api/conversations/pin.test.ts @@ -97,6 +97,9 @@ describe("conversation pin API", () => { await addParticipant(id, other.viewer.id); } + const detailPath = `http://localhost/api/conversations/${older}`; + const unpinnedEtag = (await app.request(detailPath)).headers.get("etag")!; + const pin = await setPin(app, older, true); expect(pin.status).toBe(200); expect(pinConversationResponseSchema.parse(await pin.json())).toEqual({ @@ -112,10 +115,13 @@ describe("conversation pin API", () => { { conversationId: newer, pinned: false }, { conversationId: older, pinned: false }, ]); + // A pin is viewer state, so an open detail page revalidates. + const pinnedDetail = await app.request(detailPath, { + headers: { "if-none-match": unpinnedEtag }, + }); + expect(pinnedDetail.status).toBe(200); expect( - conversationDetailReportSchema.parse( - await readConversationDetail(older, { viewer }), - ).pinnedAt, + conversationDetailReportSchema.parse(await pinnedDetail.json()).pinnedAt, ).toEqual(expect.any(String)); const archive = await app.request( diff --git a/tools/oxlint/junior/index.ts b/tools/oxlint/junior/index.ts new file mode 100644 index 0000000000..bd98fec375 --- /dev/null +++ b/tools/oxlint/junior/index.ts @@ -0,0 +1,13 @@ +import { definePlugin } from "@oxlint/plugins"; + +import { touchConversationDetailRule } from "./rules/touch-conversation-detail.ts"; + +/** Oxlint rules for Junior runtime invariants. */ +const juniorPlugin = definePlugin({ + meta: { name: "junior" }, + rules: { + "touch-conversation-detail": touchConversationDetailRule, + }, +}); + +export default juniorPlugin; diff --git a/tools/oxlint/junior/package.json b/tools/oxlint/junior/package.json new file mode 100644 index 0000000000..eff4f677df --- /dev/null +++ b/tools/oxlint/junior/package.json @@ -0,0 +1,5 @@ +{ + "name": "junior-oxlint-junior", + "private": true, + "type": "module" +} diff --git a/tools/oxlint/junior/rules/touch-conversation-detail.test.ts b/tools/oxlint/junior/rules/touch-conversation-detail.test.ts new file mode 100644 index 0000000000..4427323d88 --- /dev/null +++ b/tools/oxlint/junior/rules/touch-conversation-detail.test.ts @@ -0,0 +1,87 @@ +import { describe, it } from "node:test"; +import { RuleTester } from "oxlint/plugins-dev"; + +import { touchConversationDetailRule } from "./touch-conversation-detail.ts"; + +RuleTester.describe = describe; +RuleTester.it = it; + +const missingTouch = [{ messageId: "missingTouch" }]; + +new RuleTester().run("touch-conversation-detail", touchConversationDetailRule, { + valid: [ + `async function append(db) { + await db.with(conversationDetailTouch(db, [id])).insert(juniorConversationEvents).values(rows); + }`, + `async function append(db) { + await db.insert(juniorConversationEvents).values(rows); + await touchConversationDetail(db, [id]); + }`, + `async function stop(db) { + await db.update(juniorConversations).set({ stoppedAt, detailVersion: nextDetailVersion }); + }`, + `async function create(db) { + await db.insert(juniorConversations).values(row); + }`, + `async function ensure(db) { + const insert = db.insert(juniorConversations).values(row); + await insert.onConflictDoUpdate({ target, set: { updatedAt, detailVersion: nextDetailVersion } }); + }`, + `async function read(db) { + await db.select().from(juniorConversationEvents); + }`, + `async function other(db) { + await db.insert(juniorUsers).values(row); + }`, + ], + invalid: [ + { + code: `async function append(db) { + await db.with(otherCte).insert(juniorConversationEvents).values(rows); + }`, + errors: missingTouch, + }, + { + code: `async function append(db) { + await db.insert(juniorConversationEvents).values(rows); + }`, + errors: missingTouch, + }, + { + code: `async function append(db) { + await touchConversationDetail(db, [id]); + await db.insert(juniorConversationEvents).values(rows); + }`, + errors: missingTouch, + }, + { + code: `async function append(db) { + const write = async () => db.delete(juniorConversationAnnotations).where(match); + await write(); + await touchConversationDetail(db, [id]); + }`, + errors: missingTouch, + }, + { + code: `async function rename(db) { + await db.update(juniorConversations).set({ title }); + }`, + errors: missingTouch, + }, + { + code: `async function ensure(db) { + const insert = db.insert(juniorConversations).values(row); + await insert.onConflictDoUpdate({ target, set: { updatedAt } }); + }`, + errors: missingTouch, + }, + { + code: "async function raw(db) { await db.execute(sql`update ${juniorConversationBriefs} set x = 1`); }", + errors: missingTouch, + }, + { + code: "async function raw(db) { await db.execute(sql`delete from junior_attachments where id = ${id}`); }", + errors: missingTouch, + }, + ], +}); diff --git a/tools/oxlint/junior/rules/touch-conversation-detail.ts b/tools/oxlint/junior/rules/touch-conversation-detail.ts new file mode 100644 index 0000000000..d99b9693b1 --- /dev/null +++ b/tools/oxlint/junior/rules/touch-conversation-detail.ts @@ -0,0 +1,263 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { Context, ESTree } from "@oxlint/plugins"; + +// Tables that the Conversation detail report reads. Keep this list in sync +// with `readConversationDetail`. +const detailTables = new Set([ + "juniorAttachments", + "juniorConversationAnnotations", + "juniorConversationBriefs", + "juniorConversationEvents", + "juniorConversationParticipants", + "juniorConversations", +]); +const detailTableNames = [ + "junior_attachments", + "junior_conversation_annotations", + "junior_conversation_briefs", + "junior_conversation_events", + "junior_conversation_participants", + "junior_conversations", +]; +const writeMethods = new Set(["delete", "insert", "update"]); +const touchFunction = "touchConversationDetail"; +const touchCte = "conversationDetailTouch"; +const versionKey = "detailVersion"; +const rawWritePrefix = /\b(?:insert\s+into|update|delete\s+from)\s*"?$/i; +const rawWriteNamed = new RegExp( + String.raw`\b(?:insert\s+into|update|delete\s+from)\s+"?(?:${detailTableNames.join("|")})\b`, + "i", +); + +type Write = { node: ESTree.Node; scope: ESTree.Node | null; end: number }; + +function propertyName(node: ESTree.Node): string | null { + if (node.type !== "MemberExpression") return null; + if (!node.computed && node.property.type === "Identifier") { + return node.property.name; + } + return node.property.type === "Literal" && + typeof node.property.value === "string" + ? node.property.value + : null; +} + +function enclosingFunction(node: ESTree.Node): ESTree.Node | null { + let current = node.parent; + while (current) { + if ( + current.type === "FunctionDeclaration" || + current.type === "FunctionExpression" || + current.type === "ArrowFunctionExpression" + ) { + return current; + } + current = current.parent; + } + return null; +} + +/** Return each `.method(...)` call chained onto a query builder call. */ +function chainedCalls( + call: ESTree.CallExpression, +): Array<{ name: string; call: ESTree.CallExpression }> { + const calls: Array<{ name: string; call: ESTree.CallExpression }> = []; + let current: ESTree.Node = call; + while ( + current.parent?.type === "MemberExpression" && + current.parent.object === current && + current.parent.parent?.type === "CallExpression" && + current.parent.parent.callee === current.parent + ) { + const name = propertyName(current.parent); + current = current.parent.parent; + if (name) calls.push({ name, call: current }); + } + return calls; +} + +function objectHasKey(node: ESTree.Node | undefined, key: string): boolean { + if (node?.type !== "ObjectExpression") return false; + return node.properties.some( + (property) => + property.type === "Property" && + !property.computed && + ((property.key.type === "Identifier" && property.key.name === key) || + (property.key.type === "Literal" && property.key.value === key)), + ); +} + +function objectProperty( + node: ESTree.Node | undefined, + key: string, +): ESTree.Node | undefined { + if (node?.type !== "ObjectExpression") return undefined; + for (const property of node.properties) { + if ( + property.type === "Property" && + !property.computed && + property.key.type === "Identifier" && + property.key.name === key + ) { + return property.value; + } + } + return undefined; +} + +/** + * Return the calls made on a builder, through its chain or through the + * variable that holds it, as in `const insert = db.insert(t); insert.x()`. + */ +function builderCalls( + context: Context, + call: ESTree.CallExpression, +): Array<{ name: string; call: ESTree.CallExpression }> { + const calls = chainedCalls(call); + const top: ESTree.Node = calls.at(-1)?.call ?? call; + if (top.parent?.type !== "VariableDeclarator" || top.parent.init !== top) { + return calls; + } + for (const variable of context.sourceCode.getDeclaredVariables(top.parent)) { + for (const reference of variable.references) { + const member = reference.identifier.parent; + const used = member?.parent; + if ( + member?.type !== "MemberExpression" || + used?.type !== "CallExpression" + ) + continue; + const name = propertyName(member); + if (name && used.callee === member) calls.push({ name, call: used }); + } + } + return calls; +} + +/** Report whether a write is built on `db.with(conversationDetailTouch(...))`. */ +function carriesTouchCte(call: ESTree.CallExpression): boolean { + const builder = + call.callee.type === "MemberExpression" ? call.callee.object : null; + return ( + builder?.type === "CallExpression" && + propertyName(builder.callee) === "with" && + builder.arguments.some( + (argument) => + argument.type === "CallExpression" && + argument.callee.type === "Identifier" && + argument.callee.name === touchCte, + ) + ); +} + +/** + * A `juniorConversations` write that advances `detailVersion` itself needs no + * separate touch. A plain insert creates a row whose version starts at zero, + * and detail reads count the rows of a subtree. + */ +function conversationWriteBumpsItself( + context: Context, + method: string, + call: ESTree.CallExpression, +): boolean { + const calls = builderCalls(context, call); + if (method === "update") { + const set = calls.find((item) => item.name === "set"); + return objectHasKey(set?.call.arguments[0], versionKey); + } + if (method === "insert") { + return calls + .filter((item) => item.name === "onConflictDoUpdate") + .every((upsert) => + objectHasKey( + objectProperty(upsert.call.arguments[0], "set"), + versionKey, + ), + ); + } + return false; +} + +/** Require each detail table write to advance the Conversation detail version. */ +export const touchConversationDetailRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Require writes to tables that the Conversation detail report reads to advance the Conversation detail version.", + }, + messages: { + missingTouch: + "This write changes the Conversation detail report. Build it on `db.with(conversationDetailTouch(db, ids))`, call `touchConversationDetail` after it in the same function, or set `detailVersion` on a `juniorConversations` write. Otherwise open detail pages keep serving the old report.", + }, + }, + create(context) { + const writes: Write[] = []; + const touches: Array<{ scope: ESTree.Node | null; start: number }> = []; + + return { + CallExpression(node) { + if ( + node.callee.type === "Identifier" && + node.callee.name === touchFunction + ) { + touches.push({ + scope: enclosingFunction(node), + start: node.range[0], + }); + return; + } + const method = propertyName(node.callee); + const table = node.arguments[0]; + if ( + !method || + !writeMethods.has(method) || + table?.type !== "Identifier" + ) + return; + if (!detailTables.has(table.name)) return; + if (carriesTouchCte(node)) return; + if ( + table.name === "juniorConversations" && + conversationWriteBumpsItself(context, method, node) + ) { + return; + } + const chain = chainedCalls(node); + const end = (chain.at(-1)?.call ?? node).range[1]; + writes.push({ node, scope: enclosingFunction(node), end }); + }, + TaggedTemplateExpression(node) { + if (node.tag.type !== "Identifier" || node.tag.name !== "sql") return; + const { quasis, expressions } = node.quasi; + const writesTable = + quasis.some((quasi) => rawWriteNamed.test(quasi.value.raw)) || + expressions.some((expression, index) => { + const before = quasis[index]?.value.raw ?? ""; + return ( + expression.type === "Identifier" && + detailTables.has(expression.name) && + rawWritePrefix.test(before) + ); + }); + if (writesTable) { + writes.push({ + node, + scope: enclosingFunction(node), + end: node.range[1], + }); + } + }, + "Program:exit"() { + for (const write of writes) { + const touched = touches.some( + (touch) => touch.scope === write.scope && touch.start > write.end, + ); + if (!touched) + context.report({ node: write.node, messageId: "missingTouch" }); + } + }, + }; + }, +});