diff --git a/src/databases/officer_db/migrations/0001_omniscient_photon.sql b/src/databases/officer_db/migrations/0001_omniscient_photon.sql new file mode 100644 index 00000000..d505d80b --- /dev/null +++ b/src/databases/officer_db/migrations/0001_omniscient_photon.sql @@ -0,0 +1,8 @@ +CREATE TABLE "chat_session_events" ( + "id" bigserial PRIMARY KEY NOT NULL, + "session_id" text NOT NULL, + "event" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX "idx_chat_session_events_session_id" ON "chat_session_events" USING btree ("session_id","id"); \ No newline at end of file diff --git a/src/databases/officer_db/migrations/meta/0001_snapshot.json b/src/databases/officer_db/migrations/meta/0001_snapshot.json new file mode 100644 index 00000000..b7efcb59 --- /dev/null +++ b/src/databases/officer_db/migrations/meta/0001_snapshot.json @@ -0,0 +1,1635 @@ +{ + "id": "aa436a71-d24a-488c-b237-a375ec312eae", + "prevId": "251f147c-5aeb-41e8-8f24-8a6e2892eaeb", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.passkey_challenges": { + "name": "passkey_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "passkey_challenges_user_id_users_id_fk": { + "name": "passkey_challenges_user_id_users_id_fk", + "tableFrom": "passkey_challenges", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkeys": { + "name": "passkeys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "passkeys_user_id_users_id_fk": { + "name": "passkeys_user_id_users_id_fk", + "tableFrom": "passkeys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_blacklist": { + "name": "token_blacklist", + "schema": "", + "columns": { + "jti": { + "name": "jti", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_token_blacklist_expires": { + "name": "idx_token_blacklist_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Unverified'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_changed_at": { + "name": "password_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dock_configs": { + "name": "dock_configs", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "paths": { + "name": "paths", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dock_configs_user_id_users_id_fk": { + "name": "dock_configs_user_id_users_id_fk", + "tableFrom": "dock_configs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_integrations": { + "name": "user_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_integration_id": { + "name": "server_integration_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_integrations_user_id_users_id_fk": { + "name": "user_integrations_user_id_users_id_fk", + "tableFrom": "user_integrations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_integrations_server_integration_id_server_integrations_id_fk": { + "name": "user_integrations_server_integration_id_server_integrations_id_fk", + "tableFrom": "user_integrations", + "tableTo": "server_integrations", + "columnsFrom": [ + "server_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_user_integrations_user_provider": { + "name": "uq_user_integrations_user_provider", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_settings_user_id_users_id_fk": { + "name": "user_settings_user_id_users_id_fk", + "tableFrom": "user_settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_state": { + "name": "user_state", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_state_user_id_users_id_fk": { + "name": "user_state_user_id_users_id_fk", + "tableFrom": "user_state", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_defaults": { + "name": "dashboard_defaults", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "terminals": { + "name": "terminals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "host_terminals": { + "name": "host_terminals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dashboard_defaults_user_id_users_id_fk": { + "name": "dashboard_defaults_user_id_users_id_fk", + "tableFrom": "dashboard_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dashboard_defaults_user_id_unique": { + "name": "dashboard_defaults_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboards": { + "name": "dashboards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "terminals": { + "name": "terminals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "host_terminals": { + "name": "host_terminals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dashboards_user_id_users_id_fk": { + "name": "dashboards_user_id_users_id_fk", + "tableFrom": "dashboards", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_dashboards_user_id": { + "name": "uq_dashboards_user_id", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "terminals": { + "name": "terminals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "host_terminals": { + "name": "host_terminals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_user_id_users_id_fk": { + "name": "projects_user_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_projects_user_slug": { + "name": "uq_projects_user_slug", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.screens": { + "name": "screens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "terminals": { + "name": "terminals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "host_terminals": { + "name": "host_terminals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "screens_user_id_users_id_fk": { + "name": "screens_user_id_users_id_fk", + "tableFrom": "screens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_screens_user_name": { + "name": "uq_screens_user_name", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queue_jobs": { + "name": "queue_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lane": { + "name": "lane", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "current_step": { + "name": "current_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "steps": { + "name": "steps", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_queue_jobs_status_lane": { + "name": "idx_queue_jobs_status_lane", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lane", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_queue_jobs_user": { + "name": "idx_queue_jobs_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "queue_jobs_user_id_users_id_fk": { + "name": "queue_jobs_user_id_users_id_fk", + "tableFrom": "queue_jobs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_logs": { + "name": "task_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_name": { + "name": "task_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_dir_name": { + "name": "task_dir_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entry_name": { + "name": "entry_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entry_type": { + "name": "entry_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_error": { + "name": "is_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_task_logs_user_started": { + "name": "idx_task_logs_user_started", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_logs_user_id_users_id_fk": { + "name": "task_logs_user_id_users_id_fk", + "tableFrom": "task_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.terminal_containers": { + "name": "terminal_containers", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "docker_id": { + "name": "docker_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "terminal_containers_user_id_users_id_fk": { + "name": "terminal_containers_user_id_users_id_fk", + "tableFrom": "terminal_containers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_config": { + "name": "server_config", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_integrations": { + "name": "server_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "server_integrations_provider_unique": { + "name": "server_integrations_provider_unique", + "nullsNotDistinct": false, + "columns": [ + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_accounts": { + "name": "email_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "imap_host": { + "name": "imap_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imap_port": { + "name": "imap_port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "imap_secure": { + "name": "imap_secure", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credentials": { + "name": "credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connected'" + }, + "sync_meta": { + "name": "sync_meta", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "email_accounts_user_id_users_id_fk": { + "name": "email_accounts_user_id_users_id_fk", + "tableFrom": "email_accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_email_accounts_user_email": { + "name": "uq_email_accounts_user_email", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_jobs": { + "name": "pipeline_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_dir_name": { + "name": "task_dir_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_name": { + "name": "task_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pipeline'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "total_cost": { + "name": "total_cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_pipeline_jobs_user_created": { + "name": "idx_pipeline_jobs_user_created", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_pipeline_jobs_status": { + "name": "idx_pipeline_jobs_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_jobs_user_id_users_id_fk": { + "name": "pipeline_jobs_user_id_users_id_fk", + "tableFrom": "pipeline_jobs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_session_events": { + "name": "chat_session_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_chat_session_events_session_id": { + "name": "idx_chat_session_events_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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/src/databases/officer_db/migrations/meta/_journal.json b/src/databases/officer_db/migrations/meta/_journal.json index 0df3982b..8bc0c7d4 100644 --- a/src/databases/officer_db/migrations/meta/_journal.json +++ b/src/databases/officer_db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1785018112373, "tag": "0000_eager_sir_ram", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1785110579165, + "tag": "0001_omniscient_photon", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index e38e6ac3..bf1b656a 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -77,5 +77,7 @@ export { markInterruptedJobs, } from './queries/pipeline-jobs'; +export { appendChatEvent, getChatEventsSince, pruneChatEventsOlderThan } from './queries/chat-events'; + export { db } from './db'; export * as schema from './schema'; diff --git a/src/databases/officer_db/src/queries/chat-events.ts b/src/databases/officer_db/src/queries/chat-events.ts new file mode 100644 index 00000000..df454a78 --- /dev/null +++ b/src/databases/officer_db/src/queries/chat-events.ts @@ -0,0 +1,29 @@ +import { eq, and, gt, asc, lt } from 'drizzle-orm'; +import { db } from '../db'; +import { chatSessionEvents } from '../schema'; + +/** Append one outbound event to a session's durable log; returns its global cursor id. */ +export async function appendChatEvent(sessionId: string, event: unknown): Promise { + const [row] = await db + .insert(chatSessionEvents) + .values({ sessionId, event }) + .returning({ id: chatSessionEvents.id }); + return row!.id; +} + +/** Replay everything a (re)connecting client missed: events for this session with id > cursor. */ +export async function getChatEventsSince( + sessionId: string, + cursor: number, +): Promise> { + return db + .select({ id: chatSessionEvents.id, event: chatSessionEvents.event }) + .from(chatSessionEvents) + .where(and(eq(chatSessionEvents.sessionId, sessionId), gt(chatSessionEvents.id, cursor))) + .orderBy(asc(chatSessionEvents.id)); +} + +/** Retention: drop events older than the cutoff (called periodically). */ +export async function pruneChatEventsOlderThan(cutoff: Date): Promise { + await db.delete(chatSessionEvents).where(lt(chatSessionEvents.createdAt, cutoff)); +} diff --git a/src/databases/officer_db/src/schema/chat-events.ts b/src/databases/officer_db/src/schema/chat-events.ts new file mode 100644 index 00000000..f4a3a7bc --- /dev/null +++ b/src/databases/officer_db/src/schema/chat-events.ts @@ -0,0 +1,16 @@ +import { pgTable, bigserial, text, jsonb, timestamp, index } from 'drizzle-orm/pg-core'; + +// Durable per-session event log. Every outbound chat ServerMessage is appended here with a global +// monotonic `id` (the cursor). A (re)connecting client sends its last-seen cursor and the server +// replays everything since — so background task notifications and turn output that landed while the +// client was disconnected are never lost (the durability half of the turn/session decoupling). +export const chatSessionEvents = pgTable( + 'chat_session_events', + { + id: bigserial('id', { mode: 'number' }).primaryKey(), + sessionId: text('session_id').notNull(), + event: jsonb('event').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [index('idx_chat_session_events_session_id').on(t.sessionId, t.id)], +); diff --git a/src/databases/officer_db/src/schema/index.ts b/src/databases/officer_db/src/schema/index.ts index 4de6b4ab..2600f010 100644 --- a/src/databases/officer_db/src/schema/index.ts +++ b/src/databases/officer_db/src/schema/index.ts @@ -5,3 +5,4 @@ export * from './operations'; export * from './server'; export * from './email'; export * from './pipeline-jobs'; +export * from './chat-events'; diff --git a/src/servers/api/chat/types.ts b/src/servers/api/chat/types.ts index 5cf9265c..a4eca5b8 100644 --- a/src/servers/api/chat/types.ts +++ b/src/servers/api/chat/types.ts @@ -72,6 +72,14 @@ export type ClientMessage = // Tear down the whole session (kill any in-flight turn + drop the in-memory session), not just // the current turn. Frees the session so its transcript can be resumed elsewhere. type: 'disconnect'; + } + | { + // Sent on (re)connect: re-bind this socket to the session and replay every durable event queued + // since `cursor` (the last seq the client saw). Powers transparent reconnect without losing + // background task notifications that landed while disconnected. + type: 'resume-cursor'; + sessionId: string; + cursor: number; }; export type ServerMessage = diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index e6861c40..5b439508 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -8,7 +8,7 @@ import { ensureGeneralChatSessionsCwd } from './claude-sessions'; import * as sidecar from '@@/sidecar-registry'; import { join } from 'path'; import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path'; -import { getUserSettings, getEmailAccounts } from 'officerdb'; +import { getUserSettings, getEmailAccounts, appendChatEvent, getChatEventsSince } from 'officerdb'; import { mkdirSync } from 'node:fs'; import { logger } from './logger'; @@ -90,12 +90,31 @@ const wsToSessionMap = new WeakMap(); const pingTimers = new WeakMap, ReturnType>(); const PING_INTERVAL_MS = 25_000; -function sendToClient(ws: ServerWebSocket | null, msg: ServerMessage): void { +function sendToClient(ws: ServerWebSocket | null, msg: ServerMessage, seq?: number): void { if (ws?.readyState === 1) { - ws.send(JSON.stringify(msg)); + ws.send(JSON.stringify(seq === undefined ? msg : { ...msg, seq })); } } +// Persist a durable session event to the Postgres queue (for replay across reconnects) and deliver it +// live to the attached socket with its cursor `seq`. Events are queued even while the client is +// DISCONNECTED (ws null) — that's what lets a reconnecting client replay what it missed (e.g. a +// background task:notification). Streaming deltas are ephemeral: delivered live, never persisted. +async function emitToSession(sessionId: string, msg: ServerMessage): Promise { + const ws = (sessionManager.getSession(sessionId)?.ws ?? null) as ServerWebSocket | null; + if (msg.type === 'assistant:delta') { + sendToClient(ws, msg); + return; + } + let seq: number | undefined; + try { + seq = await appendChatEvent(sessionId, msg); + } catch (err) { + logger.error('Failed to persist chat event', { sessionId, error: String(err) }); + } + sendToClient(ws, msg, seq); +} + export async function open(ws: ServerWebSocket): Promise { const timer = setInterval(() => { try { @@ -122,6 +141,8 @@ export function message(ws: ServerWebSocket, raw: string | Buffer): void await handleStop(ws); } else if (clientMsg.type === 'disconnect') { await handleDisconnect(ws); + } else if (clientMsg.type === 'resume-cursor') { + await handleResumeCursor(ws, clientMsg); } } catch (err) { logger.error('Error handling WebSocket message', { email: ws.data.email, error: String(err) }); @@ -149,11 +170,10 @@ function createEventHandler(sessionId: string, model: string, cwd: string) { const session = sessionManager.getSession(sessionId); if (!session) return; - const ws = session.ws as ServerWebSocket | null; - + // All durable sends go through emitToSession (persist + deliver with cursor seq). Deltas stay live-only. switch (event.type) { case 'delta': { - sendToClient(ws, { type: 'assistant:delta', text: event.text }); + await emitToSession(sessionId, { type: 'assistant:delta', text: event.text }); session.streamBuffer += event.text; break; } @@ -162,7 +182,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string) { // Flush streaming buffer as complete text const text = event.text || session.streamBuffer; if (text) { - sendToClient(ws, { type: 'assistant:text', text }); + await emitToSession(sessionId, { type: 'assistant:text', text }); const assistantMsg: Message = { id: randomUUID(), @@ -181,7 +201,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string) { case 'tool:start': { // Flush any pending streaming text first if (session.streamBuffer) { - sendToClient(ws, { type: 'assistant:text', text: session.streamBuffer }); + await emitToSession(sessionId, { type: 'assistant:text', text: session.streamBuffer }); const assistantMsg: Message = { id: randomUUID(), @@ -195,7 +215,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string) { session.streamBuffer = ''; } - sendToClient(ws, { + await emitToSession(sessionId, { type: 'tool:start', toolCallId: event.toolCallId, toolName: event.toolName, @@ -216,7 +236,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string) { } case 'tool:result': { - sendToClient(ws, { + await emitToSession(sessionId, { type: 'tool:result', toolCallId: event.toolCallId, output: event.output, @@ -238,7 +258,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string) { case 'result': { // Flush any remaining streaming buffer if (session.streamBuffer) { - sendToClient(ws, { type: 'assistant:text', text: session.streamBuffer }); + await emitToSession(sessionId, { type: 'assistant:text', text: session.streamBuffer }); const assistantMsg: Message = { id: randomUUID(), @@ -253,7 +273,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string) { session.streamBuffer = ''; } - sendToClient(ws, { type: 'result', sessionId, cost: event.cost }); + await emitToSession(sessionId, { type: 'result', sessionId, cost: event.cost }); session.isGenerating = false; session.meta.cost.inputTokens += event.cost.inputTokens; @@ -265,26 +285,26 @@ function createEventHandler(sessionId: string, model: string, cwd: string) { } case 'error': { - sendToClient(ws, { type: 'error', message: event.message }); + await emitToSession(sessionId, { type: 'error', message: event.message }); session.isGenerating = false; break; } case 'stopped': { - sendToClient(ws, { type: 'stopped' }); + await emitToSession(sessionId, { type: 'stopped' }); session.isGenerating = false; break; } case 'task:started': { // Background task launched (run_in_background / Monitor). Independent of turn state. - sendToClient(ws, { type: 'task:started', taskId: event.taskId, description: event.description, taskType: event.taskType }); + await emitToSession(sessionId, { type: 'task:started', taskId: event.taskId, description: event.description, taskType: event.taskType }); break; } case 'task:notification': { // Background task finished — arrives AFTER the turn's 'result'; the whole point of the fix. - sendToClient(ws, { type: 'task:notification', taskId: event.taskId, status: event.status, summary: event.summary }); + await emitToSession(sessionId, { type: 'task:notification', taskId: event.taskId, status: event.status, summary: event.summary }); break; } } @@ -585,6 +605,29 @@ async function handleDisconnect(ws: ServerWebSocket): Promise { sendToClient(ws, { type: 'disconnected' }); } +// Reconnect: re-bind this socket to the (possibly still-live) session and replay every durable event +// queued since the client's cursor — so a brief disconnect never loses turn output or a background +// task:notification. attachWs cancels the pending idle-GC. If the in-memory session was already +// GC'd, we still replay history from Postgres (new turns will respawn the session). +async function handleResumeCursor( + ws: ServerWebSocket, + msg: { sessionId: string; cursor: number }, +): Promise { + const { sessionId, cursor } = msg; + if (sessionManager.getSession(sessionId)) { + sessionManager.attachWs(sessionId, ws); + wsToSessionMap.set(ws as any, sessionId); + } + try { + const events = await getChatEventsSince(sessionId, cursor ?? 0); + for (const { id, event } of events) { + sendToClient(ws, event as ServerMessage, id); + } + } catch (err) { + logger.error('Failed to replay chat events on resume-cursor', { sessionId, error: String(err) }); + } +} + export const chatWebsocket = { open, message, diff --git a/src/workspaces/hooks/src/useChatWebSocket.ts b/src/workspaces/hooks/src/useChatWebSocket.ts index a653db7f..776da795 100644 --- a/src/workspaces/hooks/src/useChatWebSocket.ts +++ b/src/workspaces/hooks/src/useChatWebSocket.ts @@ -3,9 +3,12 @@ import { useState, useEffect, useRef } from 'react'; type UseChatWebSocketParams = { url: string; onMessage: (data: unknown) => void; + // Called on every (re)connect once the socket is OPEN — used to send a resume-cursor so a reconnect + // re-binds to the session and replays missed events (no manual navigate-away/back). + onOpen?: () => void; }; -export const useChatWebSocket = ({ url, onMessage }: UseChatWebSocketParams) => { +export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketParams) => { const [isConnected, setIsConnected] = useState(false); const socketRef = useRef(null); @@ -14,6 +17,8 @@ export const useChatWebSocket = ({ url, onMessage }: UseChatWebSocketParams) => const isCleaningUpRef = useRef(false); const onMessageRef = useRef(onMessage); onMessageRef.current = onMessage; + const onOpenRef = useRef(onOpen); + onOpenRef.current = onOpen; const connect = () => { if (isCleaningUpRef.current) return; @@ -26,6 +31,7 @@ export const useChatWebSocket = ({ url, onMessage }: UseChatWebSocketParams) => if (socketRef.current !== socket) return; setIsConnected(true); retryRef.current = 0; + onOpenRef.current?.(); }); socket.addEventListener('message', (ev) => { diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts index 577527dd..f08901f8 100644 --- a/src/workspaces/officerdev/src/hooks/useChat.ts +++ b/src/workspaces/officerdev/src/hooks/useChat.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useChatWebSocket } from 'hooks/useChatWebSocket'; import { useClient } from 'hooks/useClient'; import { useSettings } from 'state/useSettings'; @@ -48,6 +48,12 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, const [cwd, setCwd] = useState(null); const [thinkingLevel, setThinkingLevel] = useState(null); + // Resilient-transport cursor: the max durable-event `seq` seen. On (re)connect we send it as a + // resume-cursor so the server replays anything we missed (e.g. a background task:notification that + // landed while briefly disconnected). sendRef breaks the hook ↔ onOpen circular dependency. + const cursorRef = useRef(0); + const sendRef = useRef<(d: Record) => void>(() => {}); + // Track if session has started (first message sent) const [hasStarted, setHasStarted] = useState(!!preloadedMessages?.length); @@ -101,6 +107,10 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, function handleMessage(data: unknown) { const msg = data as ServerMessage; + // Advance the resume cursor for any durable (seq-carrying) event. + const seq = (data as { seq?: number }).seq; + if (typeof seq === 'number' && seq > cursorRef.current) cursorRef.current = seq; + switch (msg.type) { case 'session:init': sessionIdRef.current = msg.sessionId; @@ -223,7 +233,15 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, } } - const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage }); + // On every (re)connect, if a session is already established, re-bind + replay via resume-cursor. + // The first connect (no session yet) no-ops; the first turn establishes the session via session:init. + const onOpen = useCallback(() => { + const sid = sessionIdRef.current; + if (sid) sendRef.current({ type: 'resume-cursor', sessionId: sid, cursor: cursorRef.current }); + }, []); + + const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage, onOpen }); + sendRef.current = send; // Clean up RAF on unmount useEffect(() => {