diff --git a/CHANGELOG.md b/CHANGELOG.md index 27745c6f84..a71e97ef04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased -**Before upgrading.** Sixteen things change for an existing deployment: +**Before upgrading.** Seventeen things change for an existing deployment: - Bots now share one computer per person instead of each having its own. Migration `0069_isolated_bots` adds `agent_profiles.isolated`, off for every existing Bot. With the supervisor (laptop, Desktop, Compose), a person's computer is created the first time one of their @@ -64,6 +64,9 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. research run replaces them. - Migration `0062_action_policy_labels` adds a `labels` column to `action_policies`, defaulting to empty. Existing boundary rules keep working and show as "Custom rule" until somebody names them. +- Migration `0070_tasks` adds one table, `tasks`, naming each task's conversation (below), and one + index to `work_items`. The table starts empty. A suggestion started before the upgrade and still + queued opens a task when it runs, rather than running in the conversation it was suggested in. - On Helm's `shared` computer and the docker-compose `agent-computer`, each Bot's files now live in `computers/` on the workspace volume (below). Files saved before the upgrade stay at the root of the volume, where no Bot sees them; move them into a Bot's folder to give them back. @@ -78,6 +81,20 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. `COMPUTER_MEMORY_HIGH_PERCENT=0` and `COMPUTER_TAB_IDLE_MS=0` (Helm: `computers.memoryHighPercent` and `computers.tabIdleMs`) keep the old behaviour. +### Started work gets a conversation of its own + +Start on a suggestion now opens a task: a new conversation with the Bot where the work runs, so the +conversation the suggestion came from stays as it was. A task's status is worked out from the work +queue and the approval and question inboxes on every read. A turn that fails is tried once more, then +reads as failed with the reason in plain words and can be tried again; replying in a task's +conversation steers it, in order, even across a restart. The new `/api/tasks` routes serve it. + +A Bot OpenBot runs itself now hands anything bigger than a quick answer to a task of its own +(`start_task`) and answers "On it" where it was asked. A Bot behind a remote agent framework is not +offered the tool; its tasks start from its suggestions, or from `POST /api/tasks`, which starts one +with any of the person's Bots. `GET /api/tasks/:id/report` reads a task's conversation back as its +report: the person's decisions and replies, and the Bot's update as each turn ended, in order. + ### A computer closes browsers nobody is using when its memory runs high A computer runs one browser for each Bot that is browsing, and the only limits were a count and half diff --git a/server/drizzle.config.ts b/server/drizzle.config.ts index 12a00a4d4b..1ec31e7433 100644 --- a/server/drizzle.config.ts +++ b/server/drizzle.config.ts @@ -42,6 +42,7 @@ export default defineConfig({ "./src/db/schema/shared-use.ts", "./src/db/schema/identity.ts", "./src/db/schema/recorder.ts", + "./src/db/schema/tasks.ts", ], out: "./drizzle", dbCredentials: { diff --git a/server/drizzle/0070_tasks.sql b/server/drizzle/0070_tasks.sql new file mode 100644 index 0000000000..b2caf1066a --- /dev/null +++ b/server/drizzle/0070_tasks.sql @@ -0,0 +1,25 @@ +CREATE TABLE "tasks" ( + "id" text PRIMARY KEY NOT NULL, + "owner_user_id" text NOT NULL, + "bot_id" text NOT NULL, + "channel_id" text NOT NULL, + "title" text NOT NULL, + "why" text, + "origin" text NOT NULL, + "suggestion_id" text, + "source_app" text, + "source_link" text, + "resolved_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "tasks_origin_check" CHECK ("tasks"."origin" IN ('asked', 'suggestion')) +); +--> statement-breakpoint +ALTER TABLE "tasks" ADD CONSTRAINT "tasks_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tasks" ADD CONSTRAINT "tasks_bot_id_agents_id_fk" FOREIGN KEY ("bot_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tasks" ADD CONSTRAINT "tasks_channel_id_channels_id_fk" FOREIGN KEY ("channel_id") REFERENCES "public"."channels"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tasks" ADD CONSTRAINT "tasks_suggestion_id_proactive_suggestions_id_fk" FOREIGN KEY ("suggestion_id") REFERENCES "public"."proactive_suggestions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "tasks_owner_created_idx" ON "tasks" USING btree ("owner_user_id","created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "tasks_channel_idx" ON "tasks" USING btree ("channel_id");--> statement-breakpoint +CREATE UNIQUE INDEX "tasks_suggestion_idx" ON "tasks" USING btree ("suggestion_id");--> statement-breakpoint +CREATE INDEX "work_items_owner_task_idx" ON "work_items" USING btree ("kind",("payload"->>'ownerUserId'),("payload"->>'taskId')); \ No newline at end of file diff --git a/server/drizzle/meta/0070_snapshot.json b/server/drizzle/meta/0070_snapshot.json new file mode 100644 index 0000000000..2d12594d46 --- /dev/null +++ b/server/drizzle/meta/0070_snapshot.json @@ -0,0 +1,10962 @@ +{ + "id": "3702aa5e-a48c-4a42-af38-8c66a3d8b489", + "prevId": "ab8a3370-f719-40eb-9066-b19d682c8e6a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "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": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "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": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "upload_group": { + "name": "upload_group", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "attachments_channel_idx": { + "name": "attachments_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_uploaded_by_idx": { + "name": "attachments_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_upload_group_idx": { + "name": "attachments_upload_group_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "upload_group", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_staged_idx": { + "name": "attachments_staged_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_channel_id_channels_id_fk": { + "name": "attachments_channel_id_channels_id_fk", + "tableFrom": "attachments", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_users_id_fk": { + "name": "attachments_uploaded_by_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "initiator_id": { + "name": "initiator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_initiator_time_idx": { + "name": "audit_events_initiator_time_idx", + "columns": [ + { + "expression": "initiator_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_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()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_at": { + "name": "summary_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_source_id": { + "name": "last_message_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_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": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channels_awaiting_summary_idx": { + "name": "channels_awaiting_summary_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_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": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "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": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_instructions": { + "name": "user_instructions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_instructions_user_id_users_id_fk": { + "name": "user_instructions_user_id_users_id_fk", + "tableFrom": "user_instructions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_signed_in_at": { + "name": "last_signed_in_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" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_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": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "isolated": { + "name": "isolated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_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": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'schedule'" + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_sweeps": { + "name": "routine_sweeps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "swept_at": { + "name": "swept_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled_at": { + "name": "enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "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": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "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": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_access_requests": { + "name": "app_access_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_key": { + "name": "app_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_title": { + "name": "app_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "app_access_requests_pending_idx": { + "name": "app_access_requests_pending_idx", + "columns": [ + { + "expression": "requested_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"agent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"app_access_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "app_access_requests_status_idx": { + "name": "app_access_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_access_requests_requested_by_users_id_fk": { + "name": "app_access_requests_requested_by_users_id_fk", + "tableFrom": "app_access_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_access_requests_agent_id_agents_id_fk": { + "name": "app_access_requests_agent_id_agents_id_fk", + "tableFrom": "app_access_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "app_access_requests_kind_check": { + "name": "app_access_requests_kind_check", + "value": "\"app_access_requests\".\"kind\" IN ('enable', 'grant', 'connect_shared')" + }, + "app_access_requests_status_check": { + "name": "app_access_requests_status_check", + "value": "\"app_access_requests\".\"status\" IN ('pending', 'approved', 'dismissed', 'cancelled')" + } + }, + "isRLSEnabled": false + }, + "public.brokered_connections": { + "name": "brokered_connections", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app": { + "name": "app", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by": { + "name": "connected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brokered_connections_person_idx": { + "name": "brokered_connections_person_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'person'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_deployment_idx": { + "name": "brokered_connections_deployment_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'deployment'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_user_idx": { + "name": "brokered_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "brokered_connections_holder_check": { + "name": "brokered_connections_holder_check", + "value": "(\"brokered_connections\".\"holder\" = 'person' AND \"brokered_connections\".\"user_id\" IS NOT NULL) OR (\"brokered_connections\".\"holder\" = 'deployment' AND \"brokered_connections\".\"user_id\" IS NULL)" + }, + "brokered_connections_provider_check": { + "name": "brokered_connections_provider_check", + "value": "\"brokered_connections\".\"provider\" IN ('composio', 'arcade')" + } + }, + "isRLSEnabled": false + }, + "public.composio_connections": { + "name": "composio_connections", + "schema": "", + "columns": { + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "composio_connections_user_idx": { + "name": "composio_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "composio_connections_toolkit_user_id_pk": { + "name": "composio_connections_toolkit_user_id_pk", + "columns": [ + "toolkit", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auth_scheme": { + "name": "auth_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_mode": { + "name": "account_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_vendor_user_id": { + "name": "shared_vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_grants": { + "name": "owner_grants", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "skipped_tools": { + "name": "skipped_tools", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "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": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_account_mode_check": { + "name": "mcp_servers_account_mode_check", + "value": "\"mcp_servers\".\"account_mode\" IS NULL OR \"mcp_servers\".\"account_mode\" IN ('personal', 'shared')" + }, + "mcp_servers_owner_grants_check": { + "name": "mcp_servers_owner_grants_check", + "value": "\"mcp_servers\".\"owner_grants\" IS NULL OR \"mcp_servers\".\"owner_grants\" IN ('any', 'admin')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor_name": { + "name": "vendor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destructive": { + "name": "destructive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_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": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_authorizations": { + "name": "pending_authorizations", + "schema": "", + "columns": { + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_id": { + "name": "auth_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "pending_authorizations_server_idx": { + "name": "pending_authorizations_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_authorizations_actor_id_users_id_fk": { + "name": "pending_authorizations_actor_id_users_id_fk", + "tableFrom": "pending_authorizations", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_authorizations_server_id_mcp_servers_id_fk": { + "name": "pending_authorizations_server_id_mcp_servers_id_fk", + "tableFrom": "pending_authorizations", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pending_authorizations_holder_check": { + "name": "pending_authorizations_holder_check", + "value": "(\"pending_authorizations\".\"holder\" = 'person' AND \"pending_authorizations\".\"user_id\" IS NOT NULL) OR (\"pending_authorizations\".\"holder\" = 'deployment' AND \"pending_authorizations\".\"user_id\" IS NULL)" + }, + "pending_authorizations_outcome_check": { + "name": "pending_authorizations_outcome_check", + "value": "\"pending_authorizations\".\"outcome\" IS NULL OR \"pending_authorizations\".\"outcome\" IN ('completed', 'failed', 'unreconciled', 'withdrawn')" + }, + "pending_authorizations_completion_check": { + "name": "pending_authorizations_completion_check", + "value": "(\"pending_authorizations\".\"completed_at\" IS NULL) = (\"pending_authorizations\".\"outcome\" IS NULL)" + }, + "pending_authorizations_user_is_actor_check": { + "name": "pending_authorizations_user_is_actor_check", + "value": "\"pending_authorizations\".\"user_id\" IS NULL OR \"pending_authorizations\".\"user_id\" = \"pending_authorizations\".\"actor_id\"" + } + }, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_as": { + "name": "granted_as", + "type": "text", + "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": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "plugin_grants_granted_as_check": { + "name": "plugin_grants_granted_as_check", + "value": "\"plugin_grants\".\"granted_as\" IS NULL OR \"plugin_grants\".\"granted_as\" IN ('admin', 'owner')" + } + }, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "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": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "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": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_open_thread_idx": { + "name": "work_items_open_thread_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "(\"payload\"->>'threadId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"work_items\".\"finished_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_owner_task_idx": { + "name": "work_items_owner_task_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "(\"payload\"->>'ownerUserId')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "(\"payload\"->>'taskId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voice_sessions": { + "name": "voice_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_message_id": { + "name": "anchor_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "transcript": { + "name": "transcript", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_status": { + "name": "summary_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'failed'" + } + }, + "indexes": { + "voice_sessions_channel_started_idx": { + "name": "voice_sessions_channel_started_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "voice_sessions_channel_id_channels_id_fk": { + "name": "voice_sessions_channel_id_channels_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_sessions_user_id_users_id_fk": { + "name": "voice_sessions_user_id_users_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "voice_sessions_duration_check": { + "name": "voice_sessions_duration_check", + "value": "\"voice_sessions\".\"duration_seconds\" >= 0" + }, + "voice_sessions_summary_check": { + "name": "voice_sessions_summary_check", + "value": "(\"voice_sessions\".\"summary_status\" = 'ready' AND \"voice_sessions\".\"summary\" IS NOT NULL) OR (\"voice_sessions\".\"summary_status\" = 'failed' AND \"voice_sessions\".\"summary\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.learning_open_skills": { + "name": "learning_open_skills", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "skill_name": { + "name": "skill_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "learning_open_skills_user_id_users_id_fk": { + "name": "learning_open_skills_user_id_users_id_fk", + "tableFrom": "learning_open_skills", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "learning_open_skills_user_id_skill_name_pk": { + "name": "learning_open_skills_user_id_skill_name_pk", + "columns": [ + "user_id", + "skill_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.learning_person_spaces": { + "name": "learning_person_spaces", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "learning_person_spaces_user_id_users_id_fk": { + "name": "learning_person_spaces_user_id_users_id_fk", + "tableFrom": "learning_person_spaces", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "learning_person_spaces_container_id_unique": { + "name": "learning_person_spaces_container_id_unique", + "nullsNotDistinct": false, + "columns": [ + "container_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.learning_settings": { + "name": "learning_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "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.learning_skill_sharing": { + "name": "learning_skill_sharing", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'closed'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "learning_skill_sharing_open_idx": { + "name": "learning_skill_sharing_open_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"learning_skill_sharing\".\"mode\" <> 'closed'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "learning_skill_sharing_user_id_users_id_fk": { + "name": "learning_skill_sharing_user_id_users_id_fk", + "tableFrom": "learning_skill_sharing", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "learning_skill_sharing_mode_check": { + "name": "learning_skill_sharing_mode_check", + "value": "\"learning_skill_sharing\".\"mode\" IN ('closed', 'open', 'choose')" + } + }, + "isRLSEnabled": false + }, + "public.learning_thread_bindings": { + "name": "learning_thread_bindings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "learning_thread_bindings_user_id_thread_id_pk": { + "name": "learning_thread_bindings_user_id_thread_id_pk", + "columns": [ + "user_id", + "thread_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibilities": { + "name": "responsibilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success_criteria": { + "name": "success_criteria", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_result": { + "name": "last_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscriptions": { + "name": "subscriptions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_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": { + "responsibilities_owner_status_idx": { + "name": "responsibilities_owner_status_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibilities_owner_user_id_users_id_fk": { + "name": "responsibilities_owner_user_id_users_id_fk", + "tableFrom": "responsibilities", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibilities_agent_id_agents_id_fk": { + "name": "responsibilities_agent_id_agents_id_fk", + "tableFrom": "responsibilities", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_events": { + "name": "responsibility_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_events_source_id_idx": { + "name": "responsibility_events_source_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_events_owner_user_id_users_id_fk": { + "name": "responsibility_events_owner_user_id_users_id_fk", + "tableFrom": "responsibility_events", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_runs": { + "name": "responsibility_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "reply_text": { + "name": "reply_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_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()" + } + }, + "indexes": { + "responsibility_runs_event_idx": { + "name": "responsibility_runs_event_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_runs_status_idx": { + "name": "responsibility_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_runs_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_runs_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_runs_event_id_responsibility_events_id_fk": { + "name": "responsibility_runs_event_id_responsibility_events_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibility_events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_source_bindings": { + "name": "responsibility_source_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_source_bindings_owner_idx": { + "name": "responsibility_source_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_source_bindings_owner_user_id_users_id_fk": { + "name": "responsibility_source_bindings_owner_user_id_users_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_source_bindings_credential_id_credentials_id_fk": { + "name": "responsibility_source_bindings_credential_id_credentials_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_triggers": { + "name": "responsibility_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_triggers_owner_idx": { + "name": "responsibility_triggers_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_responsibility_idx": { + "name": "responsibility_triggers_responsibility_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_kind_idx": { + "name": "responsibility_triggers_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_triggers_owner_user_id_users_id_fk": { + "name": "responsibility_triggers_owner_user_id_users_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_triggers_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_credential_id_credentials_id_fk": { + "name": "responsibility_triggers_credential_id_credentials_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_preferences": { + "name": "approval_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_review": { + "name": "auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "host_commands": { + "name": "host_commands", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ask'" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_preferences_owner_user_id_users_id_fk": { + "name": "approval_preferences_owner_user_id_users_id_fk", + "tableFrom": "approval_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_preferences_host_commands_check": { + "name": "approval_preferences_host_commands_check", + "value": "\"approval_preferences\".\"host_commands\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.approval_requests": { + "name": "approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_digest": { + "name": "action_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_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": { + "approval_requests_action_idx": { + "name": "approval_requests_action_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_requests_inbox_idx": { + "name": "approval_requests_inbox_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_requests_owner_user_id_users_id_fk": { + "name": "approval_requests_owner_user_id_users_id_fk", + "tableFrom": "approval_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_status_check": { + "name": "approval_status_check", + "value": "\"approval_requests\".\"status\" IN ('pending', 'approved', 'denied', 'consumed', 'completed')" + }, + "approval_decision_check": { + "name": "approval_decision_check", + "value": "\"approval_requests\".\"decision\" IN ('allow_once', 'allow_always', 'deny', 'handled')" + } + }, + "isRLSEnabled": false + }, + "public.approval_rules": { + "name": "approval_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "revoked_at": { + "name": "revoked_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()" + } + }, + "indexes": { + "approval_rules_owner_idx": { + "name": "approval_rules_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_rules_owner_user_id_users_id_fk": { + "name": "approval_rules_owner_user_id_users_id_fk", + "tableFrom": "approval_rules", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_rules_behaviour_check": { + "name": "approval_rules_behaviour_check", + "value": "\"approval_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_rules": { + "name": "approval_team_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_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()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_rules_created_by_users_id_fk": { + "name": "approval_team_rules_created_by_users_id_fk", + "tableFrom": "approval_team_rules", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_rules_behaviour_check": { + "name": "approval_team_rules_behaviour_check", + "value": "\"approval_team_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_settings": { + "name": "approval_team_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enforce_auto_review": { + "name": "enforce_auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "custom_rules_enabled": { + "name": "custom_rules_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "host_commands_cap": { + "name": "host_commands_cap", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_settings_updated_by_users_id_fk": { + "name": "approval_team_settings_updated_by_users_id_fk", + "tableFrom": "approval_team_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_settings_host_commands_cap_check": { + "name": "approval_team_settings_host_commands_cap_check", + "value": "\"approval_team_settings\".\"host_commands_cap\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.memory_sources": { + "name": "memory_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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": { + "memory_sources_owner_idx": { + "name": "memory_sources_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_sources_due_idx": { + "name": "memory_sources_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_sources_owner_user_id_users_id_fk": { + "name": "memory_sources_owner_user_id_users_id_fk", + "tableFrom": "memory_sources", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_sources_agent_id_agents_id_fk": { + "name": "memory_sources_agent_id_agents_id_fk", + "tableFrom": "memory_sources", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.personal_memories": { + "name": "personal_memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_digest": { + "name": "import_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "review_state": { + "name": "review_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unreviewed'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "formed_by": { + "name": "formed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "formed_by_agent_id": { + "name": "formed_by_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "intelligence_memory_id": { + "name": "intelligence_memory_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "intelligence_digest": { + "name": "intelligence_digest", + "type": "text", + "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": { + "personal_memories_owner_idx": { + "name": "personal_memories_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_intelligence_idx": { + "name": "personal_memories_intelligence_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "intelligence_memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_idx": { + "name": "personal_memories_formed_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "formed_by_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_import_idx": { + "name": "personal_memories_import_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_digest_idx": { + "name": "personal_memories_formed_digest_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "import_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"personal_memories\".\"formed_by\" = 'bot'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "personal_memories_owner_user_id_users_id_fk": { + "name": "personal_memories_owner_user_id_users_id_fk", + "tableFrom": "personal_memories", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_source_id_memory_sources_id_fk": { + "name": "personal_memories_source_id_memory_sources_id_fk", + "tableFrom": "personal_memories", + "tableTo": "memory_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_formed_by_agent_id_agents_id_fk": { + "name": "personal_memories_formed_by_agent_id_agents_id_fk", + "tableFrom": "personal_memories", + "tableTo": "agents", + "columnsFrom": [ + "formed_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.default_opt_outs": { + "name": "default_opt_outs", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "default_opt_outs_owner_user_id_users_id_fk": { + "name": "default_opt_outs_owner_user_id_users_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "default_opt_outs_agent_id_agents_id_fk": { + "name": "default_opt_outs_agent_id_agents_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "default_opt_outs_owner_user_id_agent_id_feature_scope_pk": { + "name": "default_opt_outs_owner_user_id_agent_id_feature_scope_pk", + "columns": [ + "owner_user_id", + "agent_id", + "feature", + "scope" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "default_opt_outs_feature_check": { + "name": "default_opt_outs_feature_check", + "value": "\"default_opt_outs\".\"feature\" IN ('memory_source', 'proactive_research')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_settings": { + "name": "proactive_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "focus": { + "name": "focus", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "interval_minutes": { + "name": "interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 240 + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "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": { + "proactive_settings_owner_agent_idx": { + "name": "proactive_settings_owner_agent_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_settings_due_idx": { + "name": "proactive_settings_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_settings_owner_user_id_users_id_fk": { + "name": "proactive_settings_owner_user_id_users_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_settings_agent_id_agents_id_fk": { + "name": "proactive_settings_agent_id_agents_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_settings_interval_check": { + "name": "proactive_settings_interval_check", + "value": "\"proactive_settings\".\"interval_minutes\" >= 60 AND \"proactive_settings\".\"interval_minutes\" <= 1440" + }, + "proactive_settings_status_check": { + "name": "proactive_settings_status_check", + "value": "\"proactive_settings\".\"last_status\" IN ('idle', 'running', 'succeeded', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_suggestions": { + "name": "proactive_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setting_id": { + "name": "setting_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_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()" + } + }, + "indexes": { + "proactive_suggestions_owner_idx": { + "name": "proactive_suggestions_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_suggestions_run_idx": { + "name": "proactive_suggestions_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_suggestions_owner_user_id_users_id_fk": { + "name": "proactive_suggestions_owner_user_id_users_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_agent_id_agents_id_fk": { + "name": "proactive_suggestions_agent_id_agents_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_setting_id_proactive_settings_id_fk": { + "name": "proactive_suggestions_setting_id_proactive_settings_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "proactive_settings", + "columnsFrom": [ + "setting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_suggestions_status_check": { + "name": "proactive_suggestions_status_check", + "value": "\"proactive_suggestions\".\"status\" IN ('open', 'dismissed', 'started', 'superseded')" + } + }, + "isRLSEnabled": false + }, + "public.demonstrations": { + "name": "demonstrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'recording'" + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skill_slug": { + "name": "skill_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "demonstrations_owner_idx": { + "name": "demonstrations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "demonstrations_active_bot_idx": { + "name": "demonstrations_active_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"demonstrations\".\"status\" = 'recording'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "demonstrations_owner_user_id_users_id_fk": { + "name": "demonstrations_owner_user_id_users_id_fk", + "tableFrom": "demonstrations", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "demonstrations_bot_id_agents_id_fk": { + "name": "demonstrations_bot_id_agents_id_fk", + "tableFrom": "demonstrations", + "tableTo": "agents", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_bindings": { + "name": "delivery_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mention_id": { + "name": "mention_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "opted_out_at": { + "name": "opted_out_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()" + } + }, + "indexes": { + "delivery_binding_identity_idx": { + "name": "delivery_binding_identity_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_bindings_owner_idx": { + "name": "delivery_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_bindings_owner_user_id_users_id_fk": { + "name": "delivery_bindings_owner_user_id_users_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_channel_id_channels_id_fk": { + "name": "delivery_bindings_channel_id_channels_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_agent_id_agents_id_fk": { + "name": "delivery_bindings_agent_id_agents_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_challenges": { + "name": "delivery_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_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()" + } + }, + "indexes": {}, + "foreignKeys": { + "delivery_challenges_owner_user_id_users_id_fk": { + "name": "delivery_challenges_owner_user_id_users_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_channel_id_channels_id_fk": { + "name": "delivery_challenges_channel_id_channels_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_agent_id_agents_id_fk": { + "name": "delivery_challenges_agent_id_agents_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_inbox": { + "name": "delivery_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "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()" + } + }, + "indexes": { + "delivery_inbox_dedupe_idx": { + "name": "delivery_inbox_dedupe_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_inbox_owner_idx": { + "name": "delivery_inbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_inbox_owner_user_id_users_id_fk": { + "name": "delivery_inbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_channel_id_channels_id_fk": { + "name": "delivery_inbox_channel_id_channels_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_agent_id_agents_id_fk": { + "name": "delivery_inbox_agent_id_agents_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_outbox": { + "name": "delivery_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "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()" + } + }, + "indexes": { + "delivery_outbox_owner_idx": { + "name": "delivery_outbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_outbox_provider_idx": { + "name": "delivery_outbox_provider_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_outbox_owner_user_id_users_id_fk": { + "name": "delivery_outbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_channel_id_channels_id_fk": { + "name": "delivery_outbox_channel_id_channels_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_agent_id_agents_id_fk": { + "name": "delivery_outbox_agent_id_agents_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "delivery_outbox_dedupe_key_unique": { + "name": "delivery_outbox_dedupe_key_unique", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_devices": { + "name": "push_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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()" + } + }, + "indexes": { + "push_devices_owner_idx": { + "name": "push_devices_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_devices_owner_user_id_users_id_fk": { + "name": "push_devices_owner_user_id_users_id_fk", + "tableFrom": "push_devices", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_devices_token_unique": { + "name": "push_devices_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_bot_threads": { + "name": "group_bot_threads", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_bot_threads_thread_idx": { + "name": "group_bot_threads_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_bot_threads_owner_user_id_users_id_fk": { + "name": "group_bot_threads_owner_user_id_users_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_bot_threads_channel_id_channels_id_fk": { + "name": "group_bot_threads_channel_id_channels_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_bot_threads_owner_user_id_channel_id_agent_id_pk": { + "name": "group_bot_threads_owner_user_id_channel_id_agent_id_pk", + "columns": [ + "owner_user_id", + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_messages": { + "name": "group_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_messages_channel_idx": { + "name": "group_messages_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_messages_channel_id_channels_id_fk": { + "name": "group_messages_channel_id_channels_id_fk", + "tableFrom": "group_messages", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_messages_owner_user_id_users_id_fk": { + "name": "group_messages_owner_user_id_users_id_fk", + "tableFrom": "group_messages", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "group_messages_status_check": { + "name": "group_messages_status_check", + "value": "\"group_messages\".\"status\" IN ('queued', 'running', 'waiting', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.action_records": { + "name": "action_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "action_records_created_idx": { + "name": "action_records_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_records_bot_idx": { + "name": "action_records_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "action_records_surface_check": { + "name": "action_records_surface_check", + "value": "\"action_records\".\"surface\" IN ('cloud', 'local')" + } + }, + "isRLSEnabled": false + }, + "public.capability_settings": { + "name": "capability_settings", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "capability_settings_scope_kind_scope_id_capability_pk": { + "name": "capability_settings_scope_kind_scope_id_capability_pk", + "columns": [ + "scope_kind", + "scope_id", + "capability" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "capability_settings_scope_kind_check": { + "name": "capability_settings_scope_kind_check", + "value": "\"capability_settings\".\"scope_kind\" IN ('organization', 'role', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.enterprise_settings": { + "name": "enterprise_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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.model_usage": { + "name": "model_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "model_usage_created_idx": { + "name": "model_usage_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "model_usage_model_idx": { + "name": "model_usage_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "model_usage_source_check": { + "name": "model_usage_source_check", + "value": "\"model_usage\".\"source\" IN ('configured', 'observed', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.network_policies": { + "name": "network_policies", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"entries\":[]}'::jsonb" + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "network_policies_scope_kind_scope_id_pk": { + "name": "network_policies_scope_kind_scope_id_pk", + "columns": [ + "scope_kind", + "scope_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "network_policies_scope_kind_check": { + "name": "network_policies_scope_kind_check", + "value": "\"network_policies\".\"scope_kind\" IN ('organization', 'group')" + }, + "network_policies_mode_check": { + "name": "network_policies_mode_check", + "value": "\"network_policies\".\"mode\" IN ('allow_all', 'defaults_plus_allowlist', 'allowlist_only')" + } + }, + "isRLSEnabled": false + }, + "public.scim_connection_bindings": { + "name": "scim_connection_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_key": { + "name": "connection_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_status": { + "name": "decommission_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "decommission_cursor_user_id": { + "name": "decommission_cursor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_reconciled_user_count": { + "name": "decommission_reconciled_user_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_batch_count": { + "name": "decommission_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_revision": { + "name": "decommission_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_completed_at": { + "name": "decommission_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_id": { + "name": "decommission_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_expires_at": { + "name": "decommission_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_connection_bindings_connection_idx": { + "name": "scim_connection_bindings_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_connection_bindings_connection_key_unique": { + "name": "scim_connection_bindings_connection_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_members": { + "name": "scim_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_members_connection_idx": { + "name": "scim_group_members_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_group_idx": { + "name": "scim_group_members_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_scim_user_idx": { + "name": "scim_group_members_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_members_group_id_scim_groups_id_fk": { + "name": "scim_group_members_group_id_scim_groups_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_members_scim_user_id_scim_users_id_fk": { + "name": "scim_group_members_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_members_membership_key_unique": { + "name": "scim_group_members_membership_key_unique", + "nullsNotDistinct": false, + "columns": [ + "membership_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_groups": { + "name": "scim_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_key": { + "name": "order_key", + "type": "text", + "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": { + "scim_groups_connection_idx": { + "name": "scim_groups_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_groups_domain_idx": { + "name": "scim_groups_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_groups_display_name_key_unique": { + "name": "scim_groups_display_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "display_name_key" + ] + }, + "scim_groups_external_id_key_unique": { + "name": "scim_groups_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_groups_order_key_unique": { + "name": "scim_groups_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_identity_tombstones": { + "name": "scim_identity_tombstones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_identity_tombstones_connection_idx": { + "name": "scim_identity_tombstones_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_domain_idx": { + "name": "scim_identity_tombstones_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_user_idx": { + "name": "scim_identity_tombstones_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_identity_tombstones_user_id_users_id_fk": { + "name": "scim_identity_tombstones_user_id_users_id_fk", + "tableFrom": "scim_identity_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_identity_tombstones_external_id_key_unique": { + "name": "scim_identity_tombstones_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grants": { + "name": "scim_projection_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_key": { + "name": "grant_key", + "type": "text", + "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": { + "scim_projection_grants_connection_idx": { + "name": "scim_projection_grants_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_domain_idx": { + "name": "scim_projection_grants_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_scim_user_idx": { + "name": "scim_projection_grants_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_user_idx": { + "name": "scim_projection_grants_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grants_scim_user_id_scim_users_id_fk": { + "name": "scim_projection_grants_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grants_user_id_users_id_fk": { + "name": "scim_projection_grants_user_id_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_projection_grants_grant_key_unique": { + "name": "scim_projection_grants_grant_key_unique", + "nullsNotDistinct": false, + "columns": [ + "grant_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_subjects": { + "name": "scim_subjects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_source_id": { + "name": "profile_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "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": { + "scim_subjects_profile_source_idx": { + "name": "scim_subjects_profile_source_idx", + "columns": [ + { + "expression": "profile_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_subjects_user_id_users_id_fk": { + "name": "scim_subjects_user_id_users_id_fk", + "tableFrom": "scim_subjects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_subjects_user_id_unique": { + "name": "scim_subjects_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_users": { + "name": "scim_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_user_key": { + "name": "connection_user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name_key": { + "name": "user_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_email_value_index": { + "name": "work_email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_value_index": { + "name": "email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serialized_emails": { + "name": "serialized_emails", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_attributes": { + "name": "serialized_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "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": { + "scim_users_connection_idx": { + "name": "scim_users_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_domain_idx": { + "name": "scim_users_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_user_idx": { + "name": "scim_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_users_user_id_users_id_fk": { + "name": "scim_users_user_id_users_id_fk", + "tableFrom": "scim_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_users_connection_user_key_unique": { + "name": "scim_users_connection_user_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_user_key" + ] + }, + "scim_users_user_name_key_unique": { + "name": "scim_users_user_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "user_name_key" + ] + }, + "scim_users_external_id_key_unique": { + "name": "scim_users_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_users_order_key_unique": { + "name": "scim_users_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_lifecycle": { + "name": "bot_lifecycle", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notify": { + "name": "notify", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_lifecycle_owner_user_id_users_id_fk": { + "name": "bot_lifecycle_owner_user_id_users_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_lifecycle_agent_id_agents_id_fk": { + "name": "bot_lifecycle_agent_id_agents_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bot_lifecycle_owner_user_id_agent_id_pk": { + "name": "bot_lifecycle_owner_user_id_agent_id_pk", + "columns": [ + "owner_user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update_routing_preferences": { + "name": "update_routing_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "update_routing_preferences_owner_user_id_users_id_fk": { + "name": "update_routing_preferences_owner_user_id_users_id_fk", + "tableFrom": "update_routing_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "update_routing_preferences_owner_user_id_kind_pk": { + "name": "update_routing_preferences_owner_user_id_kind_pk", + "columns": [ + "owner_user_id", + "kind" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_logins": { + "name": "saved_logins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "saved_logins_owner_origin_username_idx": { + "name": "saved_logins_owner_origin_username_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_logins_owner_idx": { + "name": "saved_logins_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_logins_owner_user_id_users_id_fk": { + "name": "saved_logins_owner_user_id_users_id_fk", + "tableFrom": "saved_logins", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sign_in_requests": { + "name": "sign_in_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "control_request_id": { + "name": "control_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "filling_until": { + "name": "filling_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sign_in_requests_owner_idx": { + "name": "sign_in_requests_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sign_in_requests_owner_user_id_users_id_fk": { + "name": "sign_in_requests_owner_user_id_users_id_fk", + "tableFrom": "sign_in_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sign_in_requests_status_check": { + "name": "sign_in_requests_status_check", + "value": "\"sign_in_requests\".\"status\" IN ('pending', 'filling', 'code_required', 'taken_over', 'signed_in', 'failed', 'cancelled', 'expired')" + }, + "sign_in_requests_method_check": { + "name": "sign_in_requests_method_check", + "value": "\"sign_in_requests\".\"method\" IS NULL OR \"sign_in_requests\".\"method\" IN ('typed', 'saved', 'takeover')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_assignments": { + "name": "team_bot_assignments", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_assignments_agent_id_agents_id_fk": { + "name": "team_bot_assignments_agent_id_agents_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_assignments_assigned_by_users_id_fk": { + "name": "team_bot_assignments_assigned_by_users_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "users", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_assignments_agent_id_group_name_pk": { + "name": "team_bot_assignments_agent_id_group_name_pk", + "columns": [ + "agent_id", + "group_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_audience": { + "name": "team_bot_audience", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_audience_agent_id_agents_id_fk": { + "name": "team_bot_audience_agent_id_agents_id_fk", + "tableFrom": "team_bot_audience", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_audience_agent_id_kind_value_pk": { + "name": "team_bot_audience_agent_id_kind_value_pk", + "columns": [ + "agent_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_audience_kind_check": { + "name": "team_bot_audience_kind_check", + "value": "\"team_bot_audience\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_consent_defaults": { + "name": "team_bot_consent_defaults", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consent_defaults_user_id_users_id_fk": { + "name": "team_bot_consent_defaults_user_id_users_id_fk", + "tableFrom": "team_bot_consent_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consent_defaults_user_id_server_id_pk": { + "name": "team_bot_consent_defaults_user_id_server_id_pk", + "columns": [ + "user_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_consents": { + "name": "team_bot_consents", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consents_user_id_users_id_fk": { + "name": "team_bot_consents_user_id_users_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_consents_agent_id_agents_id_fk": { + "name": "team_bot_consents_agent_id_agents_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consents_user_id_agent_id_server_id_pk": { + "name": "team_bot_consents_user_id_agent_id_server_id_pk", + "columns": [ + "user_id", + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_consents_decision_check": { + "name": "team_bot_consents_decision_check", + "value": "\"team_bot_consents\".\"decision\" IN ('always', 'once')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_publications": { + "name": "team_bot_publications", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_publications_agent_id_agents_id_fk": { + "name": "team_bot_publications_agent_id_agents_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_publications_published_by_users_id_fk": { + "name": "team_bot_publications_published_by_users_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "users", + "columnsFrom": [ + "published_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_publications_audience_check": { + "name": "team_bot_publications_audience_check", + "value": "\"team_bot_publications\".\"audience\" IN ('team', 'people')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approval_members": { + "name": "shared_use_approval_members", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approval_members_approval_fk": { + "name": "shared_use_approval_members_approval_fk", + "tableFrom": "shared_use_approval_members", + "tableTo": "shared_use_approvals", + "columnsFrom": [ + "agent_id", + "server_id" + ], + "columnsTo": [ + "agent_id", + "server_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approval_members_agent_id_server_id_kind_value_pk": { + "name": "shared_use_approval_members_agent_id_server_id_kind_value_pk", + "columns": [ + "agent_id", + "server_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approval_members_kind_check": { + "name": "shared_use_approval_members_kind_check", + "value": "\"shared_use_approval_members\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approvals": { + "name": "shared_use_approvals", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outside_input": { + "name": "outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_as": { + "name": "approved_as", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approvals_agent_id_agents_id_fk": { + "name": "shared_use_approvals_agent_id_agents_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_approvals_server_id_mcp_servers_id_fk": { + "name": "shared_use_approvals_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approvals_agent_id_server_id_pk": { + "name": "shared_use_approvals_agent_id_server_id_pk", + "columns": [ + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approvals_audience_check": { + "name": "shared_use_approvals_audience_check", + "value": "\"shared_use_approvals\".\"audience\" IN ('owner', 'people', 'team')" + }, + "shared_use_approvals_approved_as_check": { + "name": "shared_use_approvals_approved_as_check", + "value": "\"shared_use_approvals\".\"approved_as\" IS NULL OR \"shared_use_approvals\".\"approved_as\" IN ('admin', 'owner')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_requests": { + "name": "shared_use_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_audience": { + "name": "proposed_audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_outside_input": { + "name": "proposed_outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "proposed_members": { + "name": "proposed_members", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_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()" + } + }, + "indexes": { + "shared_use_requests_pending_idx": { + "name": "shared_use_requests_pending_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"shared_use_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "shared_use_requests_status_idx": { + "name": "shared_use_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_use_requests_agent_id_agents_id_fk": { + "name": "shared_use_requests_agent_id_agents_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_requests_server_id_mcp_servers_id_fk": { + "name": "shared_use_requests_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_requests_audience_check": { + "name": "shared_use_requests_audience_check", + "value": "\"shared_use_requests\".\"proposed_audience\" IN ('owner', 'people', 'team')" + }, + "shared_use_requests_reason_check": { + "name": "shared_use_requests_reason_check", + "value": "\"shared_use_requests\".\"reason\" IN ('publish', 'trigger', 'grant', 'refused_call')" + }, + "shared_use_requests_status_check": { + "name": "shared_use_requests_status_check", + "value": "\"shared_use_requests\".\"status\" IN ('pending', 'approved', 'declined', 'superseded')" + } + }, + "isRLSEnabled": false + }, + "public.identity_link_challenges": { + "name": "identity_link_challenges", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "identity_link_challenges_expiry_idx": { + "name": "identity_link_challenges_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_link_challenges_user_provider_idx": { + "name": "identity_link_challenges_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.identity_links": { + "name": "identity_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by": { + "name": "verified_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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": { + "identity_links_identity_idx": { + "name": "identity_links_identity_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_user_realm_idx": { + "name": "identity_links_user_realm_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_credential_idx": { + "name": "identity_links_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"identity_links\".\"credential_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "identity_links_credential_id_credentials_id_fk": { + "name": "identity_links_credential_id_credentials_id_fk", + "tableFrom": "identity_links", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "identity_links_verified_by_check": { + "name": "identity_links_verified_by_check", + "value": "\"identity_links\".\"verified_by\" IN ('challenge', 'oauth')" + }, + "identity_links_status_check": { + "name": "identity_links_status_check", + "value": "\"identity_links\".\"status\" IN ('active', 'needs_reconnect')" + } + }, + "isRLSEnabled": false + }, + "public.recorder_connect_codes": { + "name": "recorder_connect_codes", + "schema": "", + "columns": { + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "via": { + "name": "via", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_session": { + "name": "organization_session", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_session_expires_at": { + "name": "organization_session_expires_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()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "recorder_connect_codes_user_idx": { + "name": "recorder_connect_codes_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recorder_connect_codes_user_id_users_id_fk": { + "name": "recorder_connect_codes_user_id_users_id_fk", + "tableFrom": "recorder_connect_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recorder_connections": { + "name": "recorder_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "via": { + "name": "via", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "install_id": { + "name": "install_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_session": { + "name": "organization_session", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_session_expires_at": { + "name": "organization_session_expires_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()" + }, + "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 + }, + "activity": { + "name": "activity", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "activity_at": { + "name": "activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "recorder_connections_user_idx": { + "name": "recorder_connections_user_idx", + "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": {} + }, + "recorder_connections_live_install_idx": { + "name": "recorder_connections_live_install_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "install_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"recorder_connections\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recorder_connections_user_id_users_id_fk": { + "name": "recorder_connections_user_id_users_id_fk", + "tableFrom": "recorder_connections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "recorder_connections_token_hash_unique": { + "name": "recorder_connections_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recorder_trajectory_bindings": { + "name": "recorder_trajectory_bindings", + "schema": "", + "columns": { + "trajectory_id": { + "name": "trajectory_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recorder_trajectory_bindings_user_idx": { + "name": "recorder_trajectory_bindings_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recorder_trajectory_bindings_user_id_users_id_fk": { + "name": "recorder_trajectory_bindings_user_id_users_id_fk", + "tableFrom": "recorder_trajectory_bindings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "why": { + "name": "why", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggestion_id": { + "name": "suggestion_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_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": { + "tasks_owner_created_idx": { + "name": "tasks_owner_created_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_channel_idx": { + "name": "tasks_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_suggestion_idx": { + "name": "tasks_suggestion_idx", + "columns": [ + { + "expression": "suggestion_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_owner_user_id_users_id_fk": { + "name": "tasks_owner_user_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_bot_id_agents_id_fk": { + "name": "tasks_bot_id_agents_id_fk", + "tableFrom": "tasks", + "tableTo": "agents", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_channel_id_channels_id_fk": { + "name": "tasks_channel_id_channels_id_fk", + "tableFrom": "tasks", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_suggestion_id_proactive_suggestions_id_fk": { + "name": "tasks_suggestion_id_proactive_suggestions_id_fk", + "tableFrom": "tasks", + "tableTo": "proactive_suggestions", + "columnsFrom": [ + "suggestion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "tasks_origin_check": { + "name": "tasks_origin_check", + "value": "\"tasks\".\"origin\" IN ('asked', 'suggestion')" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui", + "remote_mastra" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped", + "waiting" + ] + }, + "public.responsibility_run_status": { + "name": "responsibility_run_status", + "schema": "public", + "values": [ + "queued", + "running", + "waiting", + "succeeded", + "failed", + "skipped" + ] + }, + "public.responsibility_status": { + "name": "responsibility_status", + "schema": "public", + "values": [ + "active", + "paused", + "completed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index ce418593e1..4759b5cbd4 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -491,6 +491,13 @@ "when": 1793224884828, "tag": "0069_isolated_bots", "breakpoints": true + }, + { + "idx": 70, + "version": "7", + "when": 1793312990622, + "tag": "0070_tasks", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/activity/activity.ts b/server/src/activity/activity.ts index de20ded105..a2973f9110 100644 --- a/server/src/activity/activity.ts +++ b/server/src/activity/activity.ts @@ -12,6 +12,7 @@ import { and, desc, eq, gte, inArray, isNull, or, sql } from "drizzle-orm"; import { HANDOFF_KIND } from "../agents/handoff"; import { WAKE_UP_KIND, type WakeUpPayload } from "../agents/wake-up"; +import { pendingPersonQuestion } from "../approvals/questions"; import type { Database } from "../db/client"; import { approvalRequests, @@ -338,9 +339,8 @@ export function createActivityStore(database: Database) { .from(workItems) .where( and( - eq(workItems.kind, "person.question"), - isNull(workItems.finishedAt), - sql`${workItems.payload}->>'actorId' = ${ownerUserId}`, + // Less any a reply in a task's conversation has already answered. + pendingPersonQuestion(ownerUserId), sql`coalesce(${workItems.payload}->>'sourceBotId', ${workItems.payload}->>'botId') = ${agentId}`, ), ) @@ -478,13 +478,7 @@ export function createActivityStore(database: Database) { total: sql`count(*)::int`, }) .from(workItems) - .where( - and( - eq(workItems.kind, "person.question"), - isNull(workItems.finishedAt), - sql`${workItems.payload}->>'actorId' = ${ownerUserId}`, - ), - ) + .where(pendingPersonQuestion(ownerUserId)) .groupBy( sql`coalesce(${workItems.payload}->>'sourceBotId', ${workItems.payload}->>'botId')`, ); diff --git a/server/src/agents/agent-resolver.ts b/server/src/agents/agent-resolver.ts index 5c7f908c19..f99f61a3df 100644 --- a/server/src/agents/agent-resolver.ts +++ b/server/src/agents/agent-resolver.ts @@ -22,6 +22,8 @@ import type { AgentActor } from "./profile-types"; export type AgentResolutionContext = { initiator?: AuditInitiator; depth?: number; + /** A task's own turn, which does the work rather than handing it to another task. */ + task?: boolean; }; export class CoworkerUnavailableError extends Error {} @@ -46,6 +48,7 @@ export type ActorAgentResolverDependencies = { loadToolsForActor?: ( actorId: string, initiator?: AuditInitiator, + turn?: { task?: boolean }, ) => LoadToolsForBot; signRunForActor?: ( actorId: string, @@ -110,7 +113,11 @@ export function createActorAgentResolver( deps.model, deps.resolveModelApiKey, deps.stallGuard, - deps.loadToolsForActor?.(actor.id, context?.initiator), + deps.loadToolsForActor?.( + actor.id, + context?.initiator, + context?.task ? { task: true } : undefined, + ), deps.signRunForActor?.(actor.id, context?.initiator, context?.depth), deps.computerGuidance, deps.loadVendors, diff --git a/server/src/app.ts b/server/src/app.ts index 2cdf12f37a..73ee2ce59f 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -143,6 +143,9 @@ import type { IntentRouter } from "./routing/classify"; import { createRoutingRoutes } from "./routing/routes"; import { createCoworkerRoutingService } from "./routing/service"; import type { SelfHostBanner } from "./self-host-banner"; +import type { TaskReportReader } from "./tasks/report"; +import { createTaskRoutes, type TaskIntake } from "./tasks/routes"; +import type { TaskStore } from "./tasks/store"; import { createTeamBotRoutes } from "./team-bots/routes"; import type { TeamBots } from "./team-bots/team-bots"; import type { PackageStatusReader } from "./tenant-package"; @@ -445,6 +448,10 @@ export function createApp( }; memory?: { store: MemoryStore; ingestion: MemoryIngestion }; proactive?: { store: ProactiveStore; engine: ProactiveEngine }; + /** A person's tasks, each in a conversation of its own. See tasks/store.ts. */ + tasks?: TaskStore; + /** A task's report, read from its conversation. See tasks/report.ts. Absent, it is not served. */ + taskReport?: TaskReportReader; responsibilities?: { store: ResponsibilityStore; engine: ResponsibilityEngine; @@ -1719,6 +1726,18 @@ export function createApp( requireUser, ), ); + if (coworker?.tasks) + app.route( + "/api/tasks", + // A task's turns run on the proactive engine, so replying and Try again are its. + createTaskRoutes(coworker.tasks, requireUser, { + retry: coworker.proactive?.engine, + report: coworker.taskReport, + ...(coworker.proactive + ? { intake: taskIntake(coworker.proactive.engine) } + : {}), + }), + ); if (coworker?.memory) app.route( "/api/memory", @@ -2089,3 +2108,10 @@ function credentialInput( actorUserId, }; } + +/** The + on Tasks starts a task the way `start_task` does, on the proactive engine. */ +function taskIntake(engine: ProactiveEngine): TaskIntake { + return { + start: (owner, botId, task) => engine.startAsked(owner, botId, task), + }; +} diff --git a/server/src/approvals/person-response-sweep.ts b/server/src/approvals/person-response-sweep.ts index 14684d1137..033c76dcfb 100644 --- a/server/src/approvals/person-response-sweep.ts +++ b/server/src/approvals/person-response-sweep.ts @@ -4,6 +4,7 @@ import type { RunAssertion } from "../agents/callback-token"; import type { AgentActor } from "../agents/profile-types"; import type { ChannelStore } from "../channels/routes"; import type { DeliveryScope } from "../delivery/types"; +import { PERSON_RESPONSE_KIND, TASK_ORDER } from "../proactive/engine"; import type { ResponsibilityStore } from "../responsibilities/types"; import type { WorkQueue } from "../work/queue"; import { resumePersonQuestion } from "./question-resume"; @@ -55,11 +56,13 @@ export function createPersonResponseTick(deps: { return async () => { try { const [item] = await approvalQueue.claim({ - kind: "person.response", + kind: PERSON_RESPONSE_KIND, owner: personResponseOwner, leaseMs: 10 * 60_000, limit: 1, maxAttempts: 1, + // An answer to a question asked in a task's conversation takes its turn there, in order. + serialBy: TASK_ORDER, }); if (!item) return; try { diff --git a/server/src/approvals/questions.ts b/server/src/approvals/questions.ts index c3de0b2c4d..940fcb29ef 100644 --- a/server/src/approvals/questions.ts +++ b/server/src/approvals/questions.ts @@ -67,6 +67,29 @@ export function listedQuestion(row: { } : null; } +/** + * The person's questions still waiting on them. + * + * Unfinished, and not answered by a reply already accepted. A reply typed into a task's + * conversation answers the questions waiting there when it was taken: its queued turn names them + * in `questionIds`, and they are answered from that moment, not only once the turn has run and + * finished them. Without this an answered question would sit in the inbox until the turn ran, + * and answering it there would send a second answer. + */ +export function pendingPersonQuestion(owner: string) { + return and( + eq(workItems.kind, "person.question"), + sql`${workItems.payload}->>'actorId' = ${owner}`, + isNull(workItems.finishedAt), + sql`not exists ( + select 1 from ${workItems} accepted_answer + where accepted_answer.kind = 'proactive.task' + and accepted_answer.payload->>'ownerUserId' = ${owner} + and accepted_answer.payload->>'followup' = 'true' + and accepted_answer.payload->'questionIds' @> jsonb_build_array(${workItems.key}) + )`, + ); +} export type ApprovalQuestions = ReturnType; export function createApprovalQuestions( database: Database, @@ -83,7 +106,7 @@ export function createApprovalQuestions( const rows = await database .select() .from(workItems) - .where(and(owned(owner), isNull(workItems.finishedAt))) + .where(pendingPersonQuestion(owner)) .orderBy(desc(workItems.createdAt)) .limit(100); return rows.map(listedQuestion).filter((row) => row !== null); @@ -110,7 +133,14 @@ export function createApprovalQuestions( .for("update") .limit(1); if (!row) throw new ApprovalNotFoundError(); - if (row.finishedAt) + // Read after the lock: under read committed this statement gets a fresh snapshot, so a + // reply accepted while the lock was being waited for counts as the answer it is. + const [pending] = await tx + .select({ key: workItems.key }) + .from(workItems) + .where(and(pendingPersonQuestion(owner), eq(workItems.key, id))) + .limit(1); + if (!pending) throw new ApprovalRefusedError("You already answered this question."); await tx .insert(workItems) diff --git a/server/src/approvals/resume-sweep.ts b/server/src/approvals/resume-sweep.ts index 2760724ba2..75311335ee 100644 --- a/server/src/approvals/resume-sweep.ts +++ b/server/src/approvals/resume-sweep.ts @@ -13,6 +13,7 @@ import { routineRuns, } from "../db/schema"; import type { DeliveryScope } from "../delivery/types"; +import { APPROVAL_RESUME_KIND, TASK_ORDER } from "../proactive/engine"; import type { ResponsibilityStore } from "../responsibilities/types"; import { DEFAULT_MAX_ATTEMPTS, type WorkQueue } from "../work/queue"; import type { ApprovalResumeDependencies, ApprovalService } from "./service"; @@ -55,10 +56,12 @@ export function createApprovalResumeTick(deps: { return async () => { try { const [item] = await approvalQueue.claim({ - kind: "approval.resume", + kind: APPROVAL_RESUME_KIND, owner: approvalOwner, leaseMs: 15 * 60_000, limit: 1, + // Allow once in a task's conversation carries its turn on there, in the task's order. + serialBy: TASK_ORDER, }); if (!item) return; // Set once the item is understood, so its last failed try can still tell the Bot. diff --git a/server/src/channels/routes.ts b/server/src/channels/routes.ts index 7d0ba8fd92..4bdc5f8208 100644 --- a/server/src/channels/routes.ts +++ b/server/src/channels/routes.ts @@ -27,6 +27,7 @@ import { channelMemberships, channels, intelligenceChannelMappings, + tasks, } from "../db/schema"; import { parsePageLimit } from "../paging"; import { @@ -54,6 +55,12 @@ export type AgentChannel = { * things to put on the screen. See the note in `channel-chat.tsx`. */ lastMessageAt: Date | null; + /** + * The task this conversation is the work of, or null for an ordinary one. A task works in a + * conversation of its own (tasks/store.ts), which the app lists under Tasks rather than among + * the person's conversations, and opens as the task. Absent reads as null. + */ + taskId?: string | null; }; /** A channel plus the last thing said in it, which is what a roster renders. */ @@ -67,8 +74,15 @@ export type ChannelSummary = AgentChannel & { pinned: boolean; /** When the caller last had this channel open, or null for never. The caller's, only. */ lastReadAt: Date | null; + /** Always read for the roster, which keeps a task's conversation out of the person's list. */ + taskId: string | null; }; +/** The task a channel is the conversation of, if any: at most one, by the task table's unique index. */ +const TASK_OF_CHANNEL = sql< + string | null +>`(select ${tasks.id} from ${tasks} where ${tasks.channelId} = ${channels.id})`; + /** What a client that ran an agent reports back about the message it just saw. */ export type ChannelActivity = { text: string; @@ -170,7 +184,15 @@ const ROSTER_ORDER = [ type ChannelTransaction = Parameters[0]>[0]; export type ChannelStore = { - create(actor: AgentActor, agentIds: string[]): Promise; + /** + * A new conversation with these Bots. On the caller's transaction when given one, so a record + * that has to name the conversation (a task's) is written with it and never seen without it. + */ + create( + actor: AgentActor, + agentIds: string[], + transaction?: ChannelTransaction, + ): Promise; /** * The one conversation this person has with this Bot alone, made if they have not had one yet. * @@ -332,7 +354,15 @@ export function createChannelStore( threadId, }); - return { id, name, agentIds, threadId, active: true, lastMessageAt: null }; + return { + id, + name, + agentIds, + threadId, + active: true, + lastMessageAt: null, + taskId: null, + }; }; /** @@ -406,7 +436,8 @@ export function createChannelStore( }; const store: ChannelStore = { - create(actor, agentIds) { + create(actor, agentIds, transaction) { + if (transaction) return makeChannel(transaction, actor, agentIds); return database.transaction( async (transaction) => makeChannel(transaction, actor, agentIds), { isolationLevel: "read committed" }, @@ -459,6 +490,12 @@ export function createChannelStore( and( isNull(channels.deletedAt), sql`(select count(*) from ${channelAgents} where ${channelAgents.channelId} = ${channels.id}) = 1`, + /* + * Not a task's. A task works in a conversation of its own with the same Bot, usually + * the most recent one, so without this the person's conversation with the Bot would + * become whichever task ran last. + */ + sql`${TASK_OF_CHANNEL} is null`, ), ) .orderBy(...ROSTER_ORDER) @@ -487,6 +524,7 @@ export function createChannelStore( threadId: intelligenceChannelMappings.threadId, lastMessageAt: channels.lastMessageAt, deletedAt: agentProfiles.deletedAt, + taskId: TASK_OF_CHANNEL, }) .from(channels) .innerJoin( @@ -521,6 +559,7 @@ export function createChannelStore( threadId: first.threadId, active: rows.every((row) => row.deletedAt === null), lastMessageAt: first.lastMessageAt, + taskId: first.taskId, }; }, @@ -599,6 +638,7 @@ export function createChannelStore( createdAt: channels.createdAt, pinnedAt: channelMemberships.pinnedAt, lastReadAt: channelMemberships.lastReadAt, + taskId: TASK_OF_CHANNEL, }) .from(channels) .innerJoin( @@ -658,6 +698,7 @@ export function createChannelStore( createdAt: row.createdAt, pinned: row.pinnedAt !== null, lastReadAt: row.lastReadAt, + taskId: row.taskId, }); } return { channels: [...summaries.values()], nextCursor }; @@ -1353,9 +1394,10 @@ export function createChannelRoutes( return routes; } -/** A channel as it goes over the wire: the same shape, with the date serialised. */ -type ChannelWire = Omit & { +/** A channel as it goes over the wire: the same shape, with the date serialised and the task said. */ +type ChannelWire = Omit & { lastMessageAt: string | null; + taskId: string | null; }; function channelDto(channel: AgentChannel): ChannelWire { @@ -1367,6 +1409,7 @@ function channelDto(channel: AgentChannel): ChannelWire { active: channel.active, // ISO-8601 so the browser gets a string it can compare, like the roster's copy. lastMessageAt: channel.lastMessageAt?.toISOString() ?? null, + taskId: channel.taskId ?? null, }; } diff --git a/server/src/channels/summary.ts b/server/src/channels/summary.ts index 749a8719b2..dcbd55c7f3 100644 --- a/server/src/channels/summary.ts +++ b/server/src/channels/summary.ts @@ -9,6 +9,7 @@ */ import { readFiring } from "@openbot/shared/routine-firing"; +import { readTaskBrief } from "@openbot/shared/task-brief"; import { and, asc, eq, isNull, notExists, sql } from "drizzle-orm"; import type { Database } from "../db/client"; import { @@ -302,9 +303,9 @@ async function openingOf( const rawAsked = textOf(question.content); if (!rawAsked) return null; // A channel a routine opened has the firing frame wrapped around its first message, not a - // person's words. Unwrapped before it can become the title, same as the transcript unwraps it - // before it can become what a person reads. - const asked = readFiring(rawAsked) ?? rawAsked; + // person's words, and so does a task's, round the person's ask. Unwrapped before it can become + // the title, same as the transcript unwraps it before it can become what a person reads. + const asked = readFiring(rawAsked) ?? readTaskBrief(rawAsked) ?? rawAsked; const replied = answer ? textOf(answer.content) : ""; return oneLine( diff --git a/server/src/channels/working.ts b/server/src/channels/working.ts index 19e735f360..263553f091 100644 --- a/server/src/channels/working.ts +++ b/server/src/channels/working.ts @@ -17,6 +17,10 @@ * and one entry here. A hop whose answer is on its way back is the same entry too: the relay is * keyed `relay:`, so it is folded into the hop it carries the answer for, and the timer a * person is watching does not restart when the answer turns around. + * + * A TASK'S TURN IS THE SAME. A task works in a conversation of its own, its turn run by the server + * with no browser streaming it (tasks/store.ts). While that turn holds its lease on the queue, the + * conversation is working on it, and the row says so for as long as the lease does. */ import { TURN_LIMITS } from "@openbot/toolkit/limits"; import { @@ -35,7 +39,8 @@ import { import { alias } from "drizzle-orm/pg-core"; import { HANDOFF_KIND } from "../agents/handoff"; import type { Database } from "../db/client"; -import { agents, routineRuns, routines, workItems } from "../db/schema"; +import { agents, routineRuns, routines, tasks, workItems } from "../db/schema"; +import { PROACTIVE_TASK_KIND } from "../proactive/engine"; import { DEFAULT_MAX_ATTEMPTS } from "../work/queue"; /** Something a conversation is waiting on. */ @@ -62,6 +67,18 @@ export type ChannelWork = botId: string; botName: string | null; since: Date; + } + | { + /** A task's own turn, running in the task's conversation. */ + kind: "task"; + /** The turn's work item. A reply and a Try again are turns of their own. */ + key: string; + taskId: string; + /** The task's Bot. */ + botId: string; + botName: string | null; + /** When the turn was taken onto the queue. */ + since: Date; }; /** The same, on the wire. */ @@ -250,6 +267,47 @@ export async function workIn( }); } + /* + * A task's turn while it runs: unfinished, and its lease live. Queued and not yet claimed, or let + * go after a throw and due its retry, nothing is running in the conversation, and the task's own + * status says what it is waiting for. Only the person's own task, in their own thread. + */ + const turns = await database + .select({ + key: workItems.key, + taskId: tasks.id, + channelId: tasks.channelId, + ownerUserId: tasks.ownerUserId, + botId: tasks.botId, + botName: agents.name, + since: workItems.createdAt, + }) + .from(workItems) + .innerJoin(tasks, eq(tasks.id, sql`${workItems.payload}->>'taskId'`)) + .leftJoin(agents, eq(agents.id, tasks.botId)) + .where( + and( + eq(workItems.kind, PROACTIVE_TASK_KIND), + isNull(workItems.finishedAt), + gt(workItems.leaseUntil, sql`now()`), + inArray( + tasks.channelId, + channels.map((channel) => channel.id), + ), + ), + ); + for (const turn of turns) { + if (ownerByChannel.get(turn.channelId) !== turn.ownerUserId) continue; + add(turn.channelId, { + kind: "task", + key: turn.key, + taskId: turn.taskId, + botId: turn.botId, + botName: turn.botName ?? null, + since: turn.since, + }); + } + for (const list of found.values()) list.sort((left, right) => left.since.getTime() - right.since.getTime()); return found; diff --git a/server/src/db/schema/index.ts b/server/src/db/schema/index.ts index 94f06ced41..53bbf55983 100644 --- a/server/src/db/schema/index.ts +++ b/server/src/db/schema/index.ts @@ -21,6 +21,7 @@ export * from "./recorder"; export * from "./responsibilities"; export * from "./scim"; export * from "./shared-use"; +export * from "./tasks"; export * from "./team-bots"; export * from "./voice"; export * from "./work"; diff --git a/server/src/db/schema/tasks.ts b/server/src/db/schema/tasks.ts new file mode 100644 index 0000000000..bf4af72c2c --- /dev/null +++ b/server/src/db/schema/tasks.ts @@ -0,0 +1,67 @@ +import { sql } from "drizzle-orm"; +import { + check, + index, + pgTable, + text, + timestamp, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import type { TaskOrigin } from "../../tasks/types"; +import { agents, channels, users } from "./core"; +import { proactiveSuggestions } from "./proactive"; + +/** + * Work a Bot does for a person in the background, each in a conversation of its own, so the + * conversation it was asked in stays one thread. Read by Tasks and the task page (tasks/store.ts). + * + * NO STATUS COLUMN. Whether a task is working, waiting on the person or ready to read is worked out + * on every read from the ledgers that already say so: the work queue, the approval inbox and the + * questions a Bot asked. A copy here would drift from them the first time one changed without it. + * Only the person's own "done" is stored, as `resolved_at`. + * + * `channel_id` is the task's own conversation, made with it, one task per conversation, and the + * task goes with it. `suggestion_id` is the suggestion it was started from: a suggestion is started + * once, so it opens one task, and the task outlives the suggestion. + */ +export const tasks = pgTable( + "tasks", + { + id: text("id").primaryKey(), + ownerUserId: text("owner_user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + botId: text("bot_id") + .notNull() + .references(() => agents.id, { onDelete: "cascade" }), + channelId: text("channel_id") + .notNull() + .references(() => channels.id, { onDelete: "cascade" }), + title: text("title").notNull(), + why: text("why"), + origin: text("origin").$type().notNull(), + suggestionId: text("suggestion_id").references( + () => proactiveSuggestions.id, + { onDelete: "set null" }, + ), + sourceApp: text("source_app"), + sourceLink: text("source_link"), + resolvedAt: timestamp("resolved_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + index("tasks_owner_created_idx").on(table.ownerUserId, table.createdAt), + uniqueIndex("tasks_channel_idx").on(table.channelId), + // Postgres keeps any number of nulls apart, so tasks asked for directly never collide. + uniqueIndex("tasks_suggestion_idx").on(table.suggestionId), + check( + "tasks_origin_check", + sql`${table.origin} IN ('asked', 'suggestion')`, + ), + ], +); diff --git a/server/src/db/schema/work.ts b/server/src/db/schema/work.ts index 00d49a3cd5..5cb59fcc7d 100644 --- a/server/src/db/schema/work.ts +++ b/server/src/db/schema/work.ts @@ -103,5 +103,18 @@ export const workItems = pgTable( index("work_items_open_thread_idx") .on(table.kind, sql`(${table.payload}->>'threadId')`) .where(sql`${table.finishedAt} IS NULL`), + /* + * One person's work of a kind, and one task's turns within it: what a task's status and report + * read, what the person's question inbox checks each question against, and how many of their + * task turns are running. Nothing purges task turns or answers, so without it each of those + * reads scans every person's, ever written. Not partial on the kind: the reads pass it as a + * parameter, and a plan made once for every value cannot tell that a partial index applies. + * See tasks/store.ts. + */ + index("work_items_owner_task_idx").on( + table.kind, + sql`(${table.payload}->>'ownerUserId')`, + sql`(${table.payload}->>'taskId')`, + ), ], ); diff --git a/server/src/index.ts b/server/src/index.ts index e33d2951f2..639593ff5f 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -282,6 +282,15 @@ import { createRoutineStore } from "./routines/store"; import { createIntentRouter } from "./routing/classify"; import { createModelCompleter } from "./routing/model"; import { createSelfHostBanner } from "./self-host-banner"; +import { createTaskReplyContext } from "./tasks/reply-context"; +import { createTaskReportReader, intelligenceHistory } from "./tasks/report"; +import { createTaskStore } from "./tasks/store"; +import { + createContinuationRunner, + createTaskThreads, + createTaskTurn, +} from "./tasks/threads"; +import { builtInBots, taskTools } from "./tasks/tools"; import { createTeamBots } from "./team-bots/team-bots"; import { createPackageStatusReader, @@ -932,7 +941,7 @@ const signInService = computerGateway resumeResponsibility: (owner, runId, result) => responsibilityStore.resumeWaiting(owner, runId, result), continueTurn: ({ ownerUserId, botId, snapshot, result, messageId }) => - approvalContinuationRunner({ + continuationRunner({ ownerUserId, routineId: "sign-in", agentId: botId, @@ -1403,6 +1412,13 @@ const deliveryScopeFor: DeliveryScopeResolver = async ( : null; }; +/* + * A person's tasks, read from the queue and the approval and question inboxes the sweeps keep. See + * tasks/store.ts. Made this early because research delivery below asks it which conversations are + * a task's own. + */ +const taskStore = createTaskStore(database); + /* * Memory sources and background research, on by default for each person and Bot, and never switched * back on once the person turns one down. See memory/defaults.ts. @@ -1415,9 +1431,11 @@ const memoryDefaults = createMemoryDefaults({ channelBots: async (ownerUserId, channelId) => (await channelStore.get(await actorFor(ownerUserId), channelId)) ?.agentIds ?? null, - // The same rule research delivery uses, read when a person speaks (long after it is defined). + // The same rule research delivery uses, read when a person speaks (long after it is defined). Never + // a task's own conversation: research writes to the person, and a task's turn works for them. canDeliver: async (ownerUserId, channelId, agentId) => - (await deliveryScopeFor(ownerUserId, channelId, agentId)) !== null, + (await deliveryScopeFor(ownerUserId, channelId, agentId)) !== null && + !(await taskStore.isTaskConversation(ownerUserId, channelId)), mintThreadId: () => threadIdentity.mint(), // Read when a Bot turn starts, long after `teamBots` below is defined. publishedByAnother: (ownerUserId, agentId) => @@ -1460,9 +1478,26 @@ const memorySweep = repeatAfterEach(async () => { // Follow-ups a Bot schedules for itself, on the shared work queue. See agents/wake-up.ts. const wakeUpStore = createWakeUpStore(database, { auditStore: bootAuditStore }); +/** Whether OpenBot runs a Bot itself: only those are offered `start_task`. See tasks/tools.ts. */ +const isBuiltInBot = builtInBots(database); + const loadToolsForActor = - (actorId: string, initiator: AuditInitiator = PERSON_INITIATOR) => + ( + actorId: string, + initiator: AuditInitiator = PERSON_INITIATOR, + turn: { task?: boolean } = {}, + ) => async (botId: string) => [ + // Hands bigger work to a task of its own. Read when a turn is built, long after the engine is. + ...(await taskTools({ + actorId, + botId, + initiator, + start: ({ ownerUserId, agentId, task }) => + proactiveEngine.startAsked(ownerUserId, agentId, task), + builtIn: isBuiltInBot, + inTask: turn.task === true, + })), ...wakeUpTools({ store: wakeUpStore, ownerUserId: actorId, @@ -1726,11 +1761,14 @@ const buildAgentFor = async ({ agentId, initiator, depth, + task, }: { ownerUserId: string; agentId: string; initiator: AuditInitiator; depth?: number; + /** A task's own turn. See `AgentResolutionContext.task`. */ + task?: boolean; }) => { const actor = await actorFor(ownerUserId); let agent: AbstractAgent | undefined; @@ -1738,6 +1776,7 @@ const buildAgentFor = async ({ agent = await actorAgentResolver.resolveAgentForActor(actor, agentId, { initiator, depth, + ...(task ? { task } : {}), }); } catch (error) { if (!(error instanceof CoworkerUnavailableError)) throw error; @@ -1807,10 +1846,10 @@ const actorAgentResolver = createActorAgentResolver({ model: runtimeModel, resolveModelApiKey: resolveRuntimeModelApiKey, stallGuard, - loadToolsForActor: (actorId, initiator) => + loadToolsForActor: (actorId, initiator, turn) => restrictToolsForRun( initiator, - loadToolsForActor(actorId, initiator), + loadToolsForActor(actorId, initiator, turn), proactiveReadOnlyRefs, ), signRunForActor, @@ -1984,6 +2023,31 @@ const approvalContinuationRunner = createTurnRunner({ copilotRuntime.learning?.containerForThread(input) ?? Promise.resolve(undefined), }); +/** + * A task's own turns: the same runner, with the Bot built for a task's turn, so it is not offered + * `start_task` there (see tasks/tools.ts). + */ +const taskTurnRunner = createTurnRunner({ + admission: serverTurnAdmission, + intelligence: routineIntelligence, + runner: routineAgentRunner, + components: componentCatalog, + buildAgentFor: (input) => buildAgentFor({ ...input, task: true }), + toolsForTurn: async ({ ownerUserId, agentId, initiator }) => + approvalComputerTools(ownerUserId, agentId, initiator), + learningContainerForThread: (input) => + copilotRuntime.learning?.containerForThread(input) ?? + Promise.resolve(undefined), +}); +/** + * Turns carried on after waiting on the person: Allow once, a question card's answer, a sign-in. + * One in a task's own conversation is the task's turn, built by `taskTurnRunner`. + */ +const continuationRunner = createContinuationRunner({ + isTaskThread: taskStore.isTaskThread, + taskTurn: taskTurnRunner, + otherwise: approvalContinuationRunner, +}); /** * Conversations with several Bots. Each Bot turn is an ordinary headless AG-UI turn into that Bot's * own group thread, built exactly as a routine's is, so governance, learning and the computer tools @@ -2067,7 +2131,7 @@ const approvalSweep = repeatAfterEach( database, responsibilityStore, groupConversations, - approvalContinuationRunner, + approvalContinuationRunner: continuationRunner, channelStore, actorFor, // Assigned further down, once delivery is wired. @@ -2160,6 +2224,22 @@ deliveryNotifications = (scope, input) => deliveryRouter.notify(scope, input); * work queue as routines. Suggestions go to the web inbox and the owner's delivery routing. */ const proactiveStore = createProactiveStore(database); +// The files and skills a reply in a task's conversation names, checked as a conversation checks them. +const taskReplyContext = createTaskReplyContext({ + load: (scope, id) => + loadAttachmentForTurn( + database, + { actorId: scope.ownerUserId, threadId: scope.threadId }, + id, + ), + markSent: (scope, ids) => + markAttachmentsSent( + database, + { actorId: scope.ownerUserId, threadId: scope.threadId }, + ids, + ), + skillsFor: (agentId) => pluginStore.listForAgent(agentId), +}); const proactiveEngine = createProactiveEngine({ store: proactiveStore, memory: personalMemoryStore, @@ -2179,30 +2259,21 @@ const proactiveEngine = createProactiveEngine({ }), resolveScope: deliveryScopeFor, notify: (scope, input) => deliveryNotifications(scope, input), - startTask: async (scope, input) => { - const actor = await actorFor(scope.ownerUserId); - await channelStore.recordActivity( - actor, - scope.channelId, - { text: input.text, agentId: null, at: new Date() }, - { id: `${input.runId}:input` }, - ); - const result = await approvalContinuationRunner({ - ...scope, - routineId: input.runId, - instruction: input.text, - userMessage: { id: input.runId, role: "user", content: input.text }, - initiator: PERSON_INITIATOR, - signal: input.signal, - }); - if (result.replyText) - await channelStore.recordActivity( - actor, - scope.channelId, - { text: result.replyText, agentId: scope.agentId, at: new Date() }, - { id: `${input.runId}:reply` }, - ); - }, + // Started work runs as a task, in a conversation of its own. See tasks/threads.ts. + tasks: createTaskThreads({ + database, + store: taskStore, + channels: channelStore, + actorFor, + scopeFor: deliveryScopeFor, + }), + startTask: createTaskTurn({ + channels: channelStore, + actorFor, + runTurn: taskTurnRunner, + replyContext: taskReplyContext, + }), + acceptReplyContext: taskReplyContext.accept, catalogue: pluginStore, deniedToolNames: async (ownerUserId, agentId) => { const [tools, allowed] = await Promise.all([ @@ -2320,7 +2391,7 @@ const personResponseSweep = repeatAfterEach( approvalQueue, personResponseOwner, authoriseQuestion, - approvalContinuationRunner, + approvalContinuationRunner: continuationRunner, responsibilityStore, responsibilityAnswerTurn, sourceForCoordinationRun, @@ -2749,6 +2820,12 @@ const app = createApp( }, memory: { store: personalMemoryStore, ingestion: memoryIngestion }, proactive: { store: proactiveStore, engine: proactiveEngine }, + tasks: taskStore, + // A task's report reads its conversation through the client every headless turn reads with. + taskReport: createTaskReportReader({ + database, + history: intelligenceHistory(routineIntelligence), + }), responsibilities: { store: responsibilityStore, engine: responsibilityEngine, diff --git a/server/src/proactive/engine.ts b/server/src/proactive/engine.ts index 67ed6318c3..38baf879a5 100644 --- a/server/src/proactive/engine.ts +++ b/server/src/proactive/engine.ts @@ -7,15 +7,37 @@ * Automatic Learning apply unchanged. The restriction is the initiator (see `restriction.ts`), which * the turn runner's `buildAgentFor` narrows on; this module adds two tools of its own and nothing * that can write outside OpenBot. + * + * STARTED WORK IS A TASK. Start on a suggestion, `start_task` from a conversation, and the + on + * Tasks each open a task: a conversation of its own with the Bot, and the record that names it + * (tasks/store.ts). Its turn runs there, queued as `PROACTIVE_TASK_KIND` and picked up straight away, + * so the conversation the person asked in stays one thread rather than splintering into the work. */ -import type { HeadlessTool } from "@openbot/toolkit/headless-tools"; +import { createHash } from "node:crypto"; +import { frameTaskBrief, frameTaskReply } from "@openbot/shared/task-brief"; +import type { + DurableTaskReply, + TaskReplyContext, +} from "@openbot/shared/task-reply"; +import { + type HeadlessTool, + HeadlessToolSuspension, +} from "@openbot/toolkit/headless-tools"; +import { ThreadLockBusyError } from "@openbot/toolkit/lease"; import type { TurnRunner } from "@openbot/toolkit/run-turn"; +import { sql } from "drizzle-orm"; import { z } from "zod"; import type { AuditInitiator } from "../audit"; import type { MemoryStore } from "../memory/store"; import { REFUSAL_MARKER } from "../plugins/tools"; -import type { WorkQueue } from "../work/queue"; +import { TASK_RUNS, type TaskOrigin } from "../tasks/types"; +import { + DEFAULT_MAX_ATTEMPTS, + type RunningCap, + type SerialOrder, + type WorkQueue, +} from "../work/queue"; import { proactiveInitiator, proactiveRunIdOf, @@ -27,13 +49,110 @@ import { MAX_SUGGESTIONS_PER_RUN, ProactiveRefusedError, type ProactiveSetting, + type ProactiveSuggestion, proactiveMemoryInputSchema, suggestionInputSchema, } from "./types"; export const PROACTIVE_RUN_KIND = "proactive.run"; export const PROACTIVE_TASK_KIND = "proactive.task"; +/** The answer to a question card, carried on by the person-response sweep. */ +export const PERSON_RESPONSE_KIND = "person.response"; +/** A decided approval, carried out and its turn carried on by the approval resume sweep. */ +export const APPROVAL_RESUME_KIND = "approval.resume"; const RUN_LEASE_MS = 10 * 60_000; +/** + * A task's lease, shorter than research's so a turn a restart cut off is picked up again within + * minutes. Longer than the thread lock's two minutes (`TURN_LIMITS.lockTtlSeconds`), so the dead + * run's lock on the conversation has gone by the time the retry asks for it. + */ +const TASK_LEASE_MS = 3 * 60_000; +/** How long a task's turn that threw waits before its one retry. */ +const TASK_RETRY_MS = 10_000; +/** + * How many of one person's task turns run at once, across every replica. The rest wait on the + * queue, still Working, and start as those finish, so a run of replies, suggestions or `start_task` + * calls never becomes dozens of headless turns at once for one person. + */ +export const TASK_TURNS_PER_PERSON = 3; +/** How many task turns one process runs at once, for everyone. The rest wait on the queue. */ +const TASK_TURNS_AT_ONCE = 10; + +/** + * One task's conversation takes one turn at a time, in the order the person gave them: its own + * turns, the person's replies, the answers to question cards its Bot asked there, and the turns + * Allow once carries on. Claimed under this by the task worker below, the person-response sweep + * (approvals/person-response-sweep.ts) and the approval resume sweep (approvals/resume-sweep.ts). + * + * Three kinds share the order. A task's turn names its task in its payload. An answer names only the + * question it answers, which names the thread it was asked in, so its task is the one whose + * conversation that thread is, read the way the task's status reads it (tasks/store.ts): the + * person's own mapping, the person's own task, and the question's channel when it names one. An + * approval's resume names only the approval, which names the thread its turn stopped in, and its + * task is found the same way. An answer or an approval anywhere else is in no task, and runs as it + * always has. + * + * A turn stopped for Allow once has finished, so without the resume in the order a reply sent before + * the person allowed it could run after the resume, or one sent after it before, and the resume + * carries on from the conversation as it was when the turn stopped. + * + * Each kind has the attempt cap its worker claims at: a task's turn runs `TASK_RUNS` times, an + * answer once, and a resume as many times as the queue's default allows. + */ +export const TASK_ORDER: SerialOrder = { + name: PROACTIVE_TASK_KIND, + kinds: { + [PROACTIVE_TASK_KIND]: TASK_RUNS, + [PERSON_RESPONSE_KIND]: 1, + [APPROVAL_RESUME_KIND]: DEFAULT_MAX_ATTEMPTS, + }, + groupOf: (row) => sql`case when ${row}.kind = ${PERSON_RESPONSE_KIND} then ( + select task.id from "tasks" as task + inner join "intelligence_channel_mappings" as mapping + on mapping.channel_id = task.channel_id + where task.owner_user_id = ${row}.payload->>'ownerUserId' + and mapping.user_id = task.owner_user_id + and ${row}.payload->'question'->>'actorId' = task.owner_user_id + and mapping.thread_id = ${row}.payload->'question'->>'threadId' + and (${row}.payload->'question'->>'channelId' is null + or ${row}.payload->'question'->>'channelId' = task.channel_id) + ) when ${row}.kind = ${APPROVAL_RESUME_KIND} then ( + select task.id from "approval_requests" as approval + inner join "intelligence_channel_mappings" as mapping + on mapping.user_id = approval.owner_user_id + and mapping.thread_id = approval.action->>'threadId' + inner join "tasks" as task + on task.channel_id = mapping.channel_id + and task.owner_user_id = approval.owner_user_id + where approval.id = ${row}.payload->>'approvalId' + and approval.owner_user_id = ${row}.payload->>'ownerUserId' + ) else ${row}.payload->>'taskId' end`, +}; + +/** + * The message id one run of a task's turn is drawn under, and its work item's readers find it by. + * + * Stable across a claim given back unstarted and across a restart, so a turn deferred behind a busy + * conversation keeps its message. Distinct for a real retry and for each Try again, so a rerun never + * collides with what the run before it left in the conversation. A reply's first run keeps the id + * the browser drew it under. + */ +export function taskExecutionId(input: { + taskId: string; + workKey: string; + attempt: number; + messageId?: string; + again?: boolean; +}): string { + const { taskId, workKey, attempt, messageId, again } = input; + if (messageId && attempt === 1) return messageId; + if (!again && attempt <= 1) return `task-${taskId}`; + const digest = createHash("sha256") + .update(`${workKey}:${attempt}`) + .digest("hex") + .slice(0, 16); + return `task-${taskId}-${digest}`; +} /** A person pressing "Run now" twice in a minute gets one run. */ const RUN_NOW_GAP_MS = 10 * 60_000; @@ -44,6 +163,86 @@ export type ProactiveScope = { threadId: string; }; +/** A task as the engine needs it: which one, its conversation, and its title. */ +export type OpenedTask = { id: string; channelId: string; title: string }; + +/** + * What a task's turn was asked, kept on its work item and carried to a rerun without the run's own + * identity, so Try again asks the same thing again. + */ +export const taskInputSchema = z.object({ + suggestionId: z.string().min(1).optional(), + text: z.string().optional(), + attachmentIds: z.array(z.string()).optional(), + skillIds: z.array(z.string()).optional(), + /** A reply the person sent in the task's conversation, rather than the task's own ask. */ + followup: z.boolean().optional(), + /** Questions waiting when the reply was taken, which it answers. Unchanged across reruns. */ + questionIds: z.array(z.string().min(1)).optional(), + /** The reply the person sent, shared by its retries and by a Try again without new words. */ + inputId: z.string().min(1).optional(), + /** The person's words with a Try again, or a reply's words as they read them. */ + reply: z.string().min(1).optional(), +}); +export type TaskInput = z.infer; + +/** + * How a task's turn ended, kept on its work item as `ending` when it finishes: the id its reply was + * recorded under in the task's conversation, and whether that reply, read whole, asked the person + * something. The conversation keeps only a short line of the reply, where a long closing question + * loses its question mark. + */ +export const taskTurnEndingSchema = z.object({ + messageId: z.string().min(1), + asks: z.boolean(), +}); +export type TaskTurnEnding = z.infer; + +/** Where a task lives: opened with a conversation of its own, and found again to run its turn. */ +export type TaskThreads = { + /** The person's questions waiting in the task's conversation, which a reply taken now answers. */ + pendingQuestions?(ownerUserId: string, taskId: string): Promise; + /** Marks those questions answered, once the reply that answers them runs. */ + answerQuestions?(ownerUserId: string, ids: readonly string[]): Promise; + /** A new conversation with this Bot, and the task record that names it. */ + open(input: { + ownerUserId: string; + agentId: string; + title: string; + why: string | null; + origin: TaskOrigin; + suggestionId: string | null; + sourceApp: string | null; + sourceLink: string | null; + }): Promise; + /** The task's own conversation, or null once the person can no longer reach it or its Bot. */ + scope(ownerUserId: string, taskId: string): Promise; + /** + * What a failed task's turn was asked, and how many turns it has had. Throws unless the task is + * the person's and failed, or was marked done after failing. + */ + failedTurn( + ownerUserId: string, + taskId: string, + ): Promise<{ ask: TaskInput; turns: number }>; + /** + * A task's turn has come off the queue, finished or let go: its conversation is no longer busy + * with it. Said here rather than by the turn as it ends, because what the conversation is waiting + * on is read from the queue (channels/working.ts), and until the turn is off it the turn is still + * on that list. + */ + settled?(ownerUserId: string, taskId: string): Promise; +}; + +/** Work handed to a task of its own: the ask in the person's words, and where it came from. */ +export type AskedTask = { + title: string; + instruction: string; + why?: string | undefined; + source?: string | undefined; + link?: string | undefined; +}; + export type ProactiveEngineDeps = { store: ProactiveStore; memory: Pick; @@ -61,10 +260,31 @@ export type ProactiveEngineDeps = { scope: ProactiveScope, input: { id: string; text: string; kind: "reply" }, ): Promise; - /** A person-started turn in their channel with this text as their message. */ + /** Opens tasks and finds their conversations again. See `TaskThreads`. */ + tasks: TaskThreads; + /** + * A person-started turn in a task's conversation with this text as their message. `final` is its + * last run: if it fails, the task has failed, and the Bot says so in the conversation. Answers + * how its reply ended, when it wrote one. + */ startTask( scope: ProactiveScope, - input: { runId: string; text: string; signal: AbortSignal }, + input: { + runId: string; + text: string; + /** The reply this run carries, as the person sent it, under the id it was taken with. */ + acceptedReply?: { id: string; text: string }; + signal: AbortSignal; + final: boolean; + } & TaskReplyContext, + ): Promise | Promise; + /** + * Checks the files and skills a reply names, and keeps the files, before the reply can wait on + * the queue. Absent, a reply with either is refused. + */ + acceptReplyContext?( + scope: ProactiveScope, + input: TaskReplyContext, ): Promise; catalogue: { listServers(): Promise< @@ -75,6 +295,10 @@ export type ProactiveEngineDeps = { deniedToolNames?(ownerUserId: string, agentId: string): Promise; mintThreadId(): string; now?: () => Date; + /** How long a task's turn that threw waits for its retry. Tests pass 0. */ + taskRetryMs?: number; + /** How many task turns this process runs at once. Tests pass fewer. */ + taskTurnsAtOnce?: number; }; /** What a setting suggested before, as `ProactiveStore.history` answers it. */ @@ -124,6 +348,18 @@ export function researchInstruction( ].join("\n"); } +/** The first paragraph of a suggestion's detail: why it matters, without the step after it. */ +function whyOf(detail: string): string | null { + return detail.split(/\n\s*\n/)[0]?.trim() || null; +} + +/** What a started suggestion says to the Bot, as the person's ask: the title, the detail, the source. */ +function suggestionAsk( + suggestion: Pick, +) { + return `${suggestion.title}\n\n${suggestion.detail}${suggestion.sourceLink ? `\n\nSource: ${suggestion.sourceLink}` : ""}`; +} + export function createProactiveEngine(deps: ProactiveEngineDeps) { const now = deps.now ?? (() => new Date()); /** Which setting a run belongs to, for the run's own tools. Local: the turn runs in this process. */ @@ -337,19 +573,54 @@ export function createProactiveEngine(deps: ProactiveEngineDeps) { } } + /** + * Claim one queued item of this kind and run it. Research runs once: a background read is not + * worth repeating. A task's turn runs up to `TASK_RUNS` times (see `drainTask`). + */ async function drain( kind: string, + /** Answers anything to keep on the item's payload as it finishes. */ work: ( payload: Record, signal: AbortSignal, - ) => Promise, + attempt: number, + workKey: string, + ) => Promise | undefined> | Promise, + options: { + leaseMs: number; + maxAttempts: number; + retryMs: number; + /** Told when a run failed and the item has another run left. */ + onRetry?: () => void; + /** Told once the item is off the queue for now, finished or let go. Never throws. */ + onSettled?: (payload: Record) => Promise; + serialBy?: SerialOrder; + runningAtMost?: RunningCap; + /** + * A turn refused the conversation's lock on its first ask is given back without spending + * its attempt. Only for work that is a single turn, with nothing earlier to repeat. + */ + deferBusy?: boolean; + } = { leaseMs: RUN_LEASE_MS, maxAttempts: 1, retryMs: 60_000 }, ) { + /* + * One owner per claim when a claim can be given back. A refund repeats the attempt number, so a + * late result from a run this worker already gave up on would otherwise match the claim that + * replaced it, on the queue's own ownership check. + */ + const claimOwner = options.deferBusy + ? `${deps.owner}:${crypto.randomUUID()}` + : deps.owner; const [item] = await deps.queue.claim({ kind, - owner: deps.owner, - leaseMs: RUN_LEASE_MS, + owner: claimOwner, + leaseMs: options.leaseMs, limit: 1, - maxAttempts: 1, + maxAttempts: options.maxAttempts, + ...(options.serialBy ? { serialBy: options.serialBy } : {}), + ...(options.runningAtMost + ? { runningAtMost: options.runningAtMost } + : {}), }); if (!item) return false; const controller = new AbortController(); @@ -358,8 +629,8 @@ export function createProactiveEngine(deps: ProactiveEngineDeps) { .renew({ kind, key: item.key, - owner: deps.owner, - leaseMs: RUN_LEASE_MS, + owner: claimOwner, + leaseMs: options.leaseMs, }) .then( (kept) => { @@ -367,23 +638,44 @@ export function createProactiveEngine(deps: ProactiveEngineDeps) { }, () => controller.abort(), ); - }, RUN_LEASE_MS / 3); + }, options.leaseMs / 3); renew.unref?.(); try { - await work(item.payload, controller.signal); - await deps.queue.finish({ kind, key: item.key, owner: deps.owner }); + const kept = await work( + item.payload, + controller.signal, + item.attempts, + item.key, + ); + await deps.queue.finish({ + kind, + key: item.key, + owner: claimOwner, + ...(kept ? { payload: kept } : {}), + }); } catch (error) { - // One attempt only: a background run is not worth repeating a partial read for. - await deps.queue.release({ + /* + * Only the turn's first ask for the lock can tell work that never began. A provider's own 409, + * or the lock lost once the run was under way, still spends its attempt and keeps its reason. + */ + const unstarted = + options.deferBusy && error instanceof ThreadLockBusyError; + const released = await deps.queue.release({ kind, key: item.key, - owner: deps.owner, - delayMs: 60_000, - reason: error instanceof Error ? error.message : String(error), + owner: claimOwner, + delayMs: options.retryMs, + ...(unstarted + ? { unstarted: true } + : { reason: error instanceof Error ? error.message : String(error) }), }); - throw error; + if (released && (unstarted || item.attempts < options.maxAttempts)) + options.onRetry?.(); + if (!unstarted) throw error; } finally { clearInterval(renew); + if (options.onSettled) + await options.onSettled(item.payload).catch(() => undefined); } return true; } @@ -392,11 +684,193 @@ export function createProactiveEngine(deps: ProactiveEngineDeps) { settingId: z.string().min(1), runId: z.string().min(1), }); - const taskSchema = z.object({ + /* + * A task's turn. One started from a suggestion carries the suggestion's id, and its ask is read + * from it; a correction carries the earlier ask in its text, as an asked task does. One queued + * before tasks existed has no `taskId`, and gets its task when it runs, so started work never + * lands in the person's own conversation again. + */ + const taskSchema = taskInputSchema.extend({ ownerUserId: z.string().min(1), - suggestionId: z.string().min(1), + taskId: z.string().min(1).optional(), + /** Queued by Try again, beside the turn that failed. */ + again: z.boolean().optional(), + /** The id the person's reply was drawn under; its first run records the message with it. */ + messageId: z.string().min(1).optional(), + /** + * How many runs this turn was queued with. A turn queued without it is from before retries, + * when one failure was final, so it is never run a second time. + */ + runs: z.number().int().positive().optional(), }); + /** Opens the task a started suggestion becomes, in its own conversation with the suggesting Bot. */ + function openFor(ownerUserId: string, suggestion: ProactiveSuggestion) { + return deps.tasks.open({ + ownerUserId, + agentId: suggestion.agentId, + title: suggestion.title, + why: whyOf(suggestion.detail), + origin: "suggestion", + suggestionId: suggestion.id, + sourceApp: suggestion.sourceApp, + sourceLink: suggestion.sourceLink, + }); + } + + /** + * Runs one queued task's turn in its own conversation, as the person's message. A rerun, whether + * the retry or Try again, is a new run with its own id, so it never collides with what the one + * before left in the conversation, and it says the ask again. + */ + async function runTask( + payload: Record, + signal: AbortSignal, + attempt: number, + workKey: string, + ) { + const work = taskSchema.parse(payload); + /* + * A turn from before retries failed once and was left, as final then. Run again now, it would + * act weeks late on whatever it was asked; refused instead, before anything is opened for it, + * so it stays failed with Try again where it has a task and opens none where it has not. + */ + if (attempt > 1 && work.runs === undefined) + throw new ProactiveRefusedError( + "This turn failed before it could be tried again.", + ); + let ask = work.text; + let taskId = work.taskId; + if (work.suggestionId) { + const suggestion = await deps.store.suggestion( + work.ownerUserId, + work.suggestionId, + ); + ask ??= suggestionAsk(suggestion); + taskId ??= (await openFor(work.ownerUserId, suggestion)).id; + } + if ( + !taskId || + ask === undefined || + (!ask && !(work.followup && work.attachmentIds?.length)) + ) + throw new ProactiveRefusedError("This task has nothing to start."); + const scope = await deps.tasks.scope(work.ownerUserId, taskId); + if (!scope) + throw new ProactiveRefusedError( + "This Bot or its channel is no longer available to you.", + ); + // A reply from before replies named their questions answers none: which ones were waiting + // when it was sent cannot be told from what is waiting now. + const priorQuestions = work.followup ? (work.questionIds ?? []) : []; + // The reply already answers them. Marked first, so a write that fails after a turn that + // succeeded can never make the turn, and what it did, run again. + await deps.tasks.answerQuestions?.(work.ownerUserId, priorQuestions); + const runId = taskExecutionId({ + taskId, + workKey, + attempt, + ...(work.messageId ? { messageId: work.messageId } : {}), + ...(work.again ? { again: work.again } : {}), + }); + /* + * A turn that stops on a request only the person can answer (an approval, a question) has + * ended where it should, not failed: finished, never retried. A retry would ask, or send, + * again. The task reads as needing input from that request (tasks/store.ts). + */ + try { + const ending = await deps.startTask(scope, { + runId, + ...(work.inputId && work.reply + ? { acceptedReply: { id: work.inputId, text: work.reply } } + : {}), + // A reply is the person's own message; the ask goes with it for the model, not in their bubble. + text: work.followup + ? ask + : work.reply + ? frameTaskReply(ask, work.reply) + : frameTaskBrief(ask), + ...(work.attachmentIds?.length + ? { attachmentIds: work.attachmentIds } + : {}), + ...(work.skillIds?.length ? { skillIds: work.skillIds } : {}), + signal, + final: attempt >= TASK_RUNS, + }); + // Kept on the turn's work item, where the task's status reads it (`TaskTurnEnding`). + return ending ? { ending } : undefined; + } catch (error) { + if (!(error instanceof HeadlessToolSuspension)) throw error; + } + } + + /** One queued task's turn, run up to `TASK_RUNS` times, the retry kicked off after its wait. */ + function drainTask() { + const retryMs = deps.taskRetryMs ?? TASK_RETRY_MS; + return drain(PROACTIVE_TASK_KIND, runTask, { + leaseMs: TASK_LEASE_MS, + serialBy: TASK_ORDER, + // The expression `work_items_owner_task_idx` indexes, so the count is a lookup by person. + runningAtMost: { + by: (row) => sql`${row}.payload->>'ownerUserId'`, + max: TASK_TURNS_PER_PERSON, + }, + deferBusy: true, + maxAttempts: TASK_RUNS, + retryMs, + onRetry: () => track(Bun.sleep(retryMs).then(startSoon)), + onSettled: async (payload) => { + const work = taskSchema.safeParse(payload); + if (work.success && work.data.taskId) + await deps.tasks.settled?.(work.data.ownerUserId, work.data.taskId); + }, + }); + } + + /* + * A task starts now, not at the next sweep a minute away: the person was just told it is under + * way. Each start claims one queued turn, so tasks started together run side by side, one per + * start, and the sweep stays the backstop for any a crash left behind. + * + * Up to `TASK_TURNS_AT_ONCE` in this process, and `TASK_TURNS_PER_PERSON` for one person. A start + * past either leaves its turn queued, and each turn that finishes looks for the next, so a turn + * held back starts as soon as there is room rather than at the next sweep. + */ + const atOnce = deps.taskTurnsAtOnce ?? TASK_TURNS_AT_ONCE; + let turnsRunning = 0; + /** `drainTask`, unless this process already runs as many task turns as it may. */ + async function drainTaskIfRoom() { + if (turnsRunning >= atOnce) return false; + turnsRunning += 1; + try { + return await drainTask(); + } finally { + turnsRunning -= 1; + } + } + const kicked = new Set>(); + function track(work: Promise) { + const tracked = work.finally(() => kicked.delete(tracked)); + kicked.add(tracked); + } + function startSoon() { + track( + drainTaskIfRoom() + .then((ran) => { + if (ran) startSoon(); + }) + .catch((error: unknown) => { + console.error( + JSON.stringify({ + type: "proactive-task-error", + error: error instanceof Error ? error.name : "UnknownError", + timestamp: new Date().toISOString(), + }), + ); + }), + ); + } + return { researchInstruction, /** The headless tools for one proactive turn; empty for any other initiator. */ @@ -465,8 +939,14 @@ export function createProactiveEngine(deps: ProactiveEngineDeps) { "dismissed", ); }, - /** The person chose to act on it: a normal, person-started turn in their channel. */ - async start(ownerUserId: string, suggestionId: string) { + /** + * The person chose to act on it: a task of its own, opened now, with a person-started turn in + * its conversation, so the conversation the suggestion was delivered to stays as it was. + */ + async start( + ownerUserId: string, + suggestionId: string, + ): Promise<{ suggestion: ProactiveSuggestion; task: OpenedTask }> { const suggestion = await deps.store.suggestion(ownerUserId, suggestionId); const setting = await deps.store.get(ownerUserId, suggestion.settingId); if ( @@ -479,17 +959,160 @@ export function createProactiveEngine(deps: ProactiveEngineDeps) { throw new ProactiveRefusedError( "This Bot or its channel is no longer available to you.", ); + // Only one Start wins: the suggestion moves from open to started once. const started = await deps.store.resolveSuggestion( ownerUserId, suggestionId, "started", ); + const task = await openFor(ownerUserId, started); await deps.queue.offer({ kind: PROACTIVE_TASK_KIND, key: suggestionId, - payload: { ownerUserId, suggestionId }, + payload: { + ownerUserId, + suggestionId, + taskId: task.id, + runs: TASK_RUNS, + }, + }); + startSoon(); + return { suggestion: started, task }; + }, + /** + * Work asked for in so many words, by `start_task` or the + on Tasks: opened as a task with its + * own conversation and started there. No cap on how many a person has. + */ + async startAsked( + ownerUserId: string, + agentId: string, + input: AskedTask, + ): Promise { + const task = await deps.tasks.open({ + ownerUserId, + agentId, + title: input.title, + why: input.why ?? null, + origin: "asked", + suggestionId: null, + sourceApp: input.source ?? null, + sourceLink: input.link ?? null, + }); + await deps.queue.offer({ + kind: PROACTIVE_TASK_KIND, + key: `task:${task.id}`, + payload: { + ownerUserId, + taskId: task.id, + text: input.instruction, + runs: TASK_RUNS, + }, + }); + startSoon(); + return task; + }, + /** + * Try again: a task whose turn couldn't finish runs it once more, in the same conversation, as a + * turn of its own beside the one that failed. Keyed on how many turns the task has had, so two + * at once run once and the second is refused. A reply the person typed on the failed task is + * their own message in the conversation, with the ask beside it for the model, so the run has + * the task and their words both. + */ + async again( + ownerUserId: string, + taskId: string, + input: { reply?: string; messageId?: string } = {}, + ): Promise { + const { ask, turns } = await deps.tasks.failedTurn(ownerUserId, taskId); + let text = ask.text; + if (input.reply) { + if (ask.followup) { + // A reply's turn already carries everything it was asked in its text. + text = frameTaskReply(text ?? "", input.reply); + } else if (ask.reply) { + // An earlier correction may never have reached the model's history. Kept in the ask + // before the new words replace it. + if (text === undefined && ask.suggestionId) + text = suggestionAsk( + await deps.store.suggestion(ownerUserId, ask.suggestionId), + ); + text = frameTaskReply(text ?? "", ask.reply); + } + } + const queued = await deps.queue.offer({ + kind: PROACTIVE_TASK_KIND, + key: `task:${taskId}:again:${turns}`, + payload: { + ownerUserId, + taskId, + ...ask, + ...(text === undefined ? {} : { text }), + ...(input.reply + ? { + reply: input.reply, + inputId: input.messageId ?? crypto.randomUUID(), + } + : {}), + ...(input.messageId ? { messageId: input.messageId } : {}), + again: true, + runs: TASK_RUNS, + }, }); - return started; + /* + * Two at once: the other got there first. Said, not answered as done, so a reply that lost + * the race goes back to the person rather than vanishing. + */ + if (queued !== "queued") + throw new ProactiveRefusedError( + "This task is already being tried again.", + ); + startSoon(); + }, + /** + * The person replied in a task's conversation. Taken onto the queue before it is answered, in + * the task's own order, so it waits behind a turn still running there and survives a restart. + * Keyed by the message, so sending it twice takes it once. + */ + async reply(ownerUserId: string, taskId: string, input: DurableTaskReply) { + const scope = await deps.tasks.scope(ownerUserId, taskId); + if (!scope) + throw new ProactiveRefusedError( + "This task is no longer available to you.", + ); + if (input.attachmentIds?.length || input.skillIds?.length) { + if (!deps.acceptReplyContext) + throw new ProactiveRefusedError( + "Files and skills are unavailable for this task.", + ); + await deps.acceptReplyContext(scope, input); + } + // The questions waiting as it is taken are the ones it answers, whenever it runs. + const questionIds = + (await deps.tasks.pendingQuestions?.(ownerUserId, taskId)) ?? []; + await deps.queue.offer({ + kind: PROACTIVE_TASK_KIND, + key: `task:${taskId}:reply:${input.messageId}`, + payload: { + ownerUserId, + taskId, + text: input.reply, + reply: input.reply || "Sent attachments", + ...(input.attachmentIds?.length + ? { attachmentIds: input.attachmentIds } + : {}), + ...(input.skillIds?.length ? { skillIds: input.skillIds } : {}), + messageId: input.messageId, + inputId: input.messageId, + followup: true, + questionIds, + runs: TASK_RUNS, + }, + }); + startSoon(); + }, + /** Settles once every task started straight away has finished its turn. For tests and shutdown. */ + async idle() { + while (kicked.size > 0) await Promise.allSettled([...kicked]); }, /** One scheduling pass: offer what is due, then run at most one research turn and one task. */ async sweep() { @@ -501,32 +1124,19 @@ export function createProactiveEngine(deps: ProactiveEngineDeps) { payload: { settingId: setting.id, runId }, }); } - await drain(PROACTIVE_RUN_KIND, async (payload, signal) => { - const { settingId, runId } = payloadSchema.parse(payload); - await run(settingId, runId, signal); - }); - await drain(PROACTIVE_TASK_KIND, async (payload, signal) => { - const { ownerUserId, suggestionId } = taskSchema.parse(payload); - const suggestion = await deps.store.suggestion( - ownerUserId, - suggestionId, - ); - const setting = await deps.store.get(ownerUserId, suggestion.settingId); - const scope = await deps.resolveScope( - ownerUserId, - setting.channelId, - suggestion.agentId, - ); - if (!scope) - throw new ProactiveRefusedError( - "This Bot or its channel is no longer available to you.", - ); - await deps.startTask(scope, { - runId: `suggestion-${suggestion.id}`, - text: `${suggestion.title}\n\n${suggestion.detail}${suggestion.sourceLink ? `\n\nSource: ${suggestion.sourceLink}` : ""}`, - signal, - }); - }); + /* + * Side by side, so a research run that throws or takes its ten minutes never holds back a + * task's turn, which is the only path a turn cut off by a restart is retried by. + */ + const outcomes = await Promise.allSettled([ + drain(PROACTIVE_RUN_KIND, async (payload, signal) => { + const { settingId, runId } = payloadSchema.parse(payload); + await run(settingId, runId, signal); + }), + drainTaskIfRoom(), + ]); + for (const outcome of outcomes) + if (outcome.status === "rejected") throw outcome.reason; }, }; } diff --git a/server/src/proactive/routes.ts b/server/src/proactive/routes.ts index f64c3616df..7f17c1a1a1 100644 --- a/server/src/proactive/routes.ts +++ b/server/src/proactive/routes.ts @@ -5,7 +5,10 @@ import type { ProactiveEngine } from "./engine"; import type { ProactiveStore } from "./store"; import { ProactiveNotFoundError, ProactiveRefusedError } from "./types"; -/** `/api/proactive`: a person's background-research settings and the suggestions they produced. */ +/** + * `/api/proactive`: a person's background-research settings, the suggestions they produced, and + * Start, which opens one as a task. + */ export function createProactiveRoutes( store: ProactiveStore, engine: ProactiveEngine, @@ -68,14 +71,24 @@ export function createProactiveRoutes( suggestions: await store.suggestions(context.var.actor.id), }), ); - routes.post("/suggestions/:id/start", async (context) => + // One of the person's suggestions, whatever its status, so a card can say Started or Dismissed. + routes.get("/suggestions/:id", async (context) => context.json({ - suggestion: await engine.start( + suggestion: await store.suggestion( context.var.actor.id, context.req.param("id"), ), }), ); + /* + * Starting one opens it as a task, in a conversation of its own (engine.ts), and answers with + * both: the suggestion, now started, and the task to open. + */ + routes.post("/suggestions/:id/start", async (context) => + context.json( + await engine.start(context.var.actor.id, context.req.param("id")), + ), + ); routes.post("/suggestions/:id/dismiss", async (context) => context.json({ suggestion: await engine.dismiss( diff --git a/server/src/tasks/reply-context.ts b/server/src/tasks/reply-context.ts new file mode 100644 index 0000000000..ad1c0a3ac0 --- /dev/null +++ b/server/src/tasks/reply-context.ts @@ -0,0 +1,87 @@ +import { + type AttachmentPart, + attachmentUrl, + classifyAttachment, +} from "@openbot/shared/attachments"; +import type { TaskReplyContext } from "@openbot/shared/task-reply"; +import { + newInlineBudget, + resolveAttachmentParts, + type StoredAttachment, +} from "../channels/attachment-parts"; +import type { ProactiveScope } from "../proactive/engine"; +import { ProactiveRefusedError } from "../proactive/types"; + +/** + * The files and skills a reply in a task's conversation names, checked the way an ordinary + * conversation checks them: each file the person's own in that conversation, each skill granted to + * the task's Bot. + * + * Only references are kept with the reply on the queue. The file's bytes and the skill's + * instructions are read when the turn runs, and checked again then, so a grant taken away while + * the reply waited is not used. + */ +export function createTaskReplyContext(deps: { + load(scope: ProactiveScope, id: string): Promise; + markSent(scope: ProactiveScope, ids: readonly string[]): Promise; + skillsFor( + agentId: string, + ): Promise<{ skills: { slug: string; instructions: string }[] }>; +}) { + async function resolve(scope: ProactiveScope, input: TaskReplyContext) { + const skillIds = [...new Set(input.skillIds ?? [])]; + const granted = skillIds.length + ? (await deps.skillsFor(scope.agentId)).skills + : []; + const skillInstructions = skillIds.map((id) => { + const skill = granted.find((entry) => entry.slug === id); + if (!skill) + throw new ProactiveRefusedError( + "A selected skill is no longer available to this Bot. Remove it and send again.", + ); + return skill.instructions; + }); + const attachments: AttachmentPart[] = []; + // Through the same checks a conversation's own files take, the per-run inline budget included. + const ids = [...new Set(input.attachmentIds ?? [])]; + await resolveAttachmentParts( + ids.map((id) => ({ + type: "document", + source: { type: "url", value: attachmentUrl(id) }, + })), + async (id) => { + const stored = await deps.load(scope, id); + if (!stored) + throw new ProactiveRefusedError( + "An attachment is no longer available in this conversation. Attach it again.", + ); + attachments.push({ + type: + classifyAttachment(stored.mimeType) === "image" + ? "image" + : "document", + source: { + type: "url", + value: attachmentUrl(id), + mimeType: stored.mimeType, + }, + metadata: { attachmentId: id, filename: stored.name }, + }); + return stored; + }, + "fail", + newInlineBudget(), + ); + return { attachments, skillInstructions }; + } + return { + resolve, + /** Checked before the reply is taken, and its files kept, since it may wait across a restart. */ + async accept(scope: ProactiveScope, input: TaskReplyContext) { + await resolve(scope, input); + if (input.attachmentIds?.length) + await deps.markSent(scope, input.attachmentIds); + }, + }; +} +export type TaskReplyContextReader = ReturnType; diff --git a/server/src/tasks/reply-ending.ts b/server/src/tasks/reply-ending.ts new file mode 100644 index 0000000000..90c7e8cf1b --- /dev/null +++ b/server/src/tasks/reply-ending.ts @@ -0,0 +1,85 @@ +/* + * How a Bot's reply ends, read for Tasks: the line a task shows as its next step, and whether the + * reply finished by asking the person something. Words only, so it reads the same wherever the + * reply came from. + */ + +/** How long a kept reply tail may be. A next step is one line on Tasks. */ +const REPLY_TAIL_MAX = 240; +/** A last sentence shorter than this is kept with the one before it. */ +const SHORT_SENTENCE = 20; + +/** A paragraph that is a list: every line of it is an item. */ +const LIST_ITEM = /^\s*(?:[-*+•]|\d+[.)])\s+/; +/** A paragraph that is something the Bot drafted for the person, not what it says to them. */ +const DRAFT = + /^\s*(?:>|```|["“'‘]|\*{0,2}_{0,2}(?:draft|subject|to|dear|hi|hello)\b[^\n]{0,40}?:)/i; +/** What can trail a question and still leave it one: closing quotes, brackets, emoji, spaces. */ +const TRAILING = /(?:[\s"”'’)\]}»\p{Extended_Pictographic}]|\uFE0F|\u200D)+$/u; + +/** Whether a sentence asks something, once what trails its question mark is set aside. */ +const asksIn = (sentence: string) => + sentence.replace(TRAILING, "").endsWith("?"); + +const plain = (line: string) => + line + .replace(/^\s*(?:[-*+>•]|#{1,6}|\d+[.)])\s+/, "") + .replace(/[*_`]+/g, "") + .trim(); + +/** + * How a reply ends, for Tasks: the sentence to show as the next step, and whether it asks the + * person something. + * + * A Bot ends a turn on what comes next, in the last paragraph it writes to the person. Lists and + * drafts that close a reply are what it prepared, not what it says, so they are set aside. Within + * that last paragraph its last question is the next step, even with a remark after it; without + * one, its last sentence is. A short closing sentence keeps the one before it, so "Do you want me + * to?" never stands alone. Null when there is nothing to say. + */ +export function replyEnding(reply: string | null | undefined): { + tail: string | null; + asks: boolean; +} { + const paragraphs = (reply ?? "") + .split(/\n\s*\n/) + .map((paragraph) => paragraph.trim()) + .filter((paragraph) => /[\p{L}\p{N}]/u.test(paragraph)); + const spoken = paragraphs.filter((paragraph) => { + const lines = paragraph.split("\n").filter((line) => line.trim()); + return !( + lines.every((line) => LIST_ITEM.test(line)) || DRAFT.test(paragraph) + ); + }); + const kept = spoken.length ? spoken : paragraphs.slice(-1); + const sentencesOf = (paragraph: string | undefined) => + (paragraph ?? "") + .split("\n") + .map(plain) + .flatMap((line) => line.split(/(?<=[.!?][\s"”'’)\]]*)\s+(?=\S)/)) + .map((sentence) => sentence.trim()) + .filter((sentence) => /[\p{L}\p{N}]/u.test(sentence)); + const sentences = sentencesOf(kept.at(-1)); + const question = sentences.findLastIndex(asksIn); + const at = question >= 0 ? question : sentences.length - 1; + const chosen = sentences[at]; + if (!chosen) return { tail: null, asks: false }; + // A paragraph of its own still leans on the paragraph before it. + const before = sentences[at - 1] ?? sentencesOf(kept.at(-2)).at(-1); + const sentence = + chosen.length < SHORT_SENTENCE && before ? `${before} ${chosen}` : chosen; + return { + tail: + sentence.length > REPLY_TAIL_MAX + ? `${Array.from(sentence) + .slice(0, REPLY_TAIL_MAX - 1) + .join("")}…` + : sentence, + asks: spoken.length > 0 && question >= 0, + }; +} + +/** The sentence `replyEnding` would show, alone. */ +export function replyTail(reply: string | null | undefined): string | null { + return replyEnding(reply).tail; +} diff --git a/server/src/tasks/report.ts b/server/src/tasks/report.ts new file mode 100644 index 0000000000..03f4850b64 --- /dev/null +++ b/server/src/tasks/report.ts @@ -0,0 +1,507 @@ +import { readTaskFrame } from "@openbot/shared/task-brief"; +import { historyOrEmpty } from "@openbot/toolkit/thread-history"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import type { Database } from "../db/client"; +import { approvalRequests, channels, workItems } from "../db/schema"; +import { PROACTIVE_TASK_KIND, taskExecutionId } from "../proactive/engine"; +import type { TaskRecord } from "./types"; + +/* + * A task's report: what was decided in it and what it said, worked out from what its conversation + * already holds. Nothing here is stored. + * + * WHERE EACH PART COMES FROM. + * - The conversation is read from Intelligence, as the runtime holds it. Its messages carry no + * times, so a time comes from whichever ledger saw that moment, or there is none. + * - Decisions are the approval inbox's: Allowed once, Always allowed or Refused, by the person, at + * `decidedAt`. Each sits where the conversation carried it on (the `approval:` message the + * resume writes), or after the call it was asked about. + * - Updates are each finished turn's last words from the Bot, cut to their first sentence. A turn + * ends when the Bot's work item finishes (the task's first turn, a turn carried on after an + * approval), when it pauses to ask for approval, or, for the latest turn, at the conversation's + * last message. + * - The person's replies are their own messages after the ask that opened the task. + */ + +/** A message as Intelligence keeps it. Only the fields the report reads. */ +export type ReportMessage = { + id: string; + role: string; + content?: unknown; + toolCalls?: { id: string; name: string; args: string }[]; + toolCallId?: string; +}; + +/** An approval asked in the task's conversation, as the inbox holds it. */ +export type ReportApproval = { + id: string; + toolCallId: string; + status: string; + decision: string | null; + createdAt: Date; + decidedAt: Date | null; + completedAt: Date | null; + action: { + toolRef: string; + effect: string; + scope: string; + args: unknown; + target?: unknown; + }; +}; + +export type ReportDecision = { + kind: "decision"; + id: string; + decision: "allow_once" | "allow_always" | "deny" | "handled"; + /** Always the person: a task's approvals are asked of the person it belongs to. */ + by: "you"; + at: string | null; + /** What was asked about, in the inbox's shape, so the app names it the way the inbox does. */ + action: ReportApproval["action"]; +}; +export type ReportReply = { + kind: "reply"; + id: string; + text: string; + at: string | null; +}; +export type ReportUpdate = { + kind: "update"; + id: string; + text: string; + at: string | null; +}; +export type ReportEntry = ReportDecision | ReportReply | ReportUpdate; + +export type TaskReport = { + taskId: string; + /** In the order they happened in the conversation. */ + entries: ReportEntry[]; +}; + +/** What the report is built from, read beforehand so the building can be tested without a database. */ +export type ReportInput = { + task: Pick; + messages: readonly ReportMessage[]; + approvals: readonly ReportApproval[]; + /** When the task's first turn finished, from its work item. */ + firstTurnEndedAt: Date | null; + /** When the turn carried on after each approval finished, from the resume's work item. */ + resumedEndedAt: ReadonlyMap; + /** The conversation's last message, from its channel. */ + lastMessageAt: Date | null; + /** Transcript openers whose particular execution finished, even while later work waits. */ + completedTurnIds?: ReadonlySet; + /** Accepted question answers, keyed by their original response message ID, without model context. */ + questionResponses?: ReadonlyMap; +}; + +const DECISIONS = new Set(["allow_once", "allow_always", "deny", "handled"]); + +/** How long an update or a reply gets before it is cut at a word. */ +const LINE_LENGTH = 160; + +/** A message's text: a string, or the text parts of structured content. */ +export function textOf(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((part) => + part && + typeof part === "object" && + (part as { type?: unknown }).type === "text" && + typeof (part as { text?: unknown }).text === "string" + ? (part as { text: string }).text + : "", + ) + .filter(Boolean) + .join("\n"); +} + +/** Markdown's marks taken off a line, so a sentence reads as words. */ +function plain(line: string): string { + return line + .replace(/^\s{0,3}(#{1,6}|>|[-*+]|\d+[.)])\s+/, "") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/(\*\*|__|`)/g, "") + .replace(/(^|\s)[*_](\S[^*_]*\S|\S)[*_](?=\s|$|[.,;:!?])/g, "$1$2") + .replace(/\s+/g, " ") + .trim(); +} + +/** Cut at a word with an ellipsis past `LINE_LENGTH`. */ +function capped(text: string): string { + if (text.length <= LINE_LENGTH) return text; + const cut = text.slice(0, LINE_LENGTH); + const space = cut.lastIndexOf(" "); + return `${(space > LINE_LENGTH / 2 ? cut.slice(0, space) : cut).replace(/[\s,;:.-]+$/, "")}…`; +} + +/** + * The first sentence of what the Bot said: its first line with words on it, up to a stop followed + * by a capital, so "Sent. Ana" ends at "Sent." but "no. 5521" and "e.g. this" do not end early. + */ +export function firstSentence(text: string): string { + const line = + text + .split("\n") + .map(plain) + .find((part) => /\p{L}|\p{N}/u.test(part)) ?? ""; + const sentence = line.split(/(?<=[^.][.!?])\s+(?=\p{Lu})/u)[0] ?? line; + return capped(sentence); +} + +/** The person's words, on one line. */ +function said(text: string): string { + return capped(text.split("\n").map(plain).filter(Boolean).join(" ")); +} + +const asString = (value: unknown): string => + typeof value === "string" ? value : ""; + +type Turn = { + id: string; + /** What opened it: the task's ask or replay, the person's reply, or an approval carried on from. */ + opener: + | { kind: "brief" } + | { kind: "reply"; id: string; text: string } + | { kind: "resume"; approvalId: string }; + messages: ReportMessage[]; +}; + +const RESUME_PREFIX = "approval:"; + +/** The conversation as turns: each opened by the ask, a reply, or an approval carried on from. */ +function turnsOf( + messages: readonly ReportMessage[], + questionResponses: ReportInput["questionResponses"], +): Turn[] { + const turns: Turn[] = []; + const acceptedInputs = new Set(); + for (const message of messages) { + if (message.role === "user") { + const text = textOf(message.content); + const frame = readTaskFrame(text); + // New execution IDs still open outcome turns, but only accepted input adds a human reply. + const replay = + frame?.inputId !== undefined && acceptedInputs.has(frame.inputId); + if (frame?.inputId) acceptedInputs.add(frame.inputId); + // Question-card answers predate task frames. Their stable message ID is also the accepted + // input ID carried by Try again, so replay adds an outcome without another human answer. + else if (message.id.startsWith("person-response:")) + acceptedInputs.add(message.id); + turns.push({ + id: message.id, + opener: + frame?.kind === "brief" || replay + ? { kind: "brief" } + : { + kind: "reply", + id: message.id, + // The ledger preserves the literal answer even if the question or answer quotes + // framing syntax. Never split the generated question wrapper to recover it. + text: questionResponses?.get(message.id) ?? frame?.text ?? text, + }, + messages: [], + }); + continue; + } + if (message.role === "tool" && message.id.startsWith(RESUME_PREFIX)) { + turns.push({ + id: message.id, + opener: { + kind: "resume", + approvalId: message.id.slice(RESUME_PREFIX.length), + }, + messages: [message], + }); + continue; + } + if (message.role !== "assistant" && message.role !== "tool") continue; + const current = turns.at(-1); + if (current) current.messages.push(message); + // Anything before the first user message (a seeded greeting) opens a turn of its own. + else + turns.push({ + id: message.id, + opener: { kind: "brief" }, + messages: [message], + }); + } + return turns; +} + +const iso = (at: Date | null | undefined) => (at ? at.toISOString() : null); + +/** The report, from what was read. Pure, so every rule above is tested without a database. */ +export function buildTaskReport(input: ReportInput): TaskReport { + const approvalsByCall = new Map( + input.approvals.map((approval) => [approval.toolCallId, approval]), + ); + const approvalsById = new Map( + input.approvals.map((approval) => [approval.id, approval]), + ); + const decided = (approval: ReportApproval | undefined) => + approval?.decision && DECISIONS.has(approval.decision) ? approval : null; + const decisionEntry = (approval: ReportApproval): ReportDecision => ({ + kind: "decision", + id: approval.id, + decision: approval.decision as ReportDecision["decision"], + by: "you", + at: iso(approval.decidedAt), + action: { + toolRef: approval.action.toolRef, + effect: approval.action.effect, + scope: approval.action.scope, + args: approval.action.args, + ...(approval.action.target === undefined + ? {} + : { target: approval.action.target }), + }, + }); + + const turns = turnsOf(input.messages, input.questionResponses); + const entries: ReportEntry[] = []; + const placed = new Set(); + turns.forEach((turn, index) => { + const last = index === turns.length - 1; + if (turn.opener.kind === "reply") { + const text = said(turn.opener.text); + if (text) + entries.push({ + kind: "reply", + id: turn.opener.id, + text, + at: null, + }); + } + if (turn.opener.kind === "resume") { + const approval = decided(approvalsById.get(turn.opener.approvalId)); + if (approval && !placed.has(approval.id)) { + placed.add(approval.id); + entries.push(decisionEntry(approval)); + } + } + + // The approvals this turn stopped to ask for: it ended when it asked. + const asked = turn.messages + .flatMap((message) => message.toolCalls ?? []) + .map((call) => approvalsByCall.get(call.id)) + .filter((approval): approval is ReportApproval => approval !== undefined); + const reply = turn.messages.findLast( + (message) => + message.role === "assistant" && textOf(message.content).trim() !== "", + ); + // Working includes future queued work. Keep a known completed execution's update, while + // still excluding the latest turn when its own completion has not reached the ledger. + const finished = + !(last && input.task.status === "working") || + input.completedTurnIds?.has(turn.id); + if (reply && finished) { + const text = firstSentence(textOf(reply.content)); + const endedAt = + asked[0]?.createdAt ?? + (turn.opener.kind === "brief" && index === 0 + ? input.firstTurnEndedAt + : turn.opener.kind === "resume" + ? (input.resumedEndedAt.get(turn.opener.approvalId) ?? + approvalsById.get(turn.opener.approvalId)?.completedAt ?? + null) + : null) ?? + (last ? input.lastMessageAt : null); + if (text) + entries.push({ kind: "update", id: reply.id, text, at: iso(endedAt) }); + } + + // A decision the conversation has not carried on from yet sits after the call it was about. + for (const approval of asked) { + const answered = decided(approval); + if (answered && !placed.has(answered.id)) { + placed.add(answered.id); + entries.push(decisionEntry(answered)); + } + } + }); + // Decided in this conversation but on a call its history no longer holds: last, in time order. + for (const approval of [...input.approvals].sort( + (a, b) => (a.decidedAt?.getTime() ?? 0) - (b.decidedAt?.getTime() ?? 0), + )) { + const answered = decided(approval); + if (answered && !placed.has(answered.id)) { + placed.add(answered.id); + entries.push(decisionEntry(answered)); + } + } + return { taskId: input.task.id, entries }; +} + +/** Reads a thread's messages for its owner. Index.ts gives it Intelligence's client. */ +export type ReportHistory = (input: { + threadId: string; + ownerUserId: string; +}) => Promise; + +export type TaskReportReader = ( + ownerUserId: string, + task: TaskRecord, +) => Promise; + +/** The history reader over an Intelligence client: a thread it has never heard of has no messages. */ +export function intelligenceHistory(client: { + getThreadMessages(params: { + threadId: string; + userId: string; + }): Promise<{ messages: ReportMessage[] }>; +}): ReportHistory { + return async ({ threadId, ownerUserId }) => + ( + await historyOrEmpty( + () => client.getThreadMessages({ threadId, userId: ownerUserId }), + { messages: [] }, + ) + ).messages; +} + +/** `GET /api/tasks/:id/report`'s reader: the ledgers, then `buildTaskReport`. */ +export function createTaskReportReader(deps: { + database: Database; + history: ReportHistory; +}): TaskReportReader { + return async (ownerUserId, task) => { + if (!task.threadId) return { taskId: task.id, entries: [] }; + const threadId = task.threadId; + const [messages, rows, turns, channel, answers] = await Promise.all([ + deps.history({ threadId, ownerUserId }), + deps.database + .select({ + id: approvalRequests.id, + toolCallId: approvalRequests.toolCallId, + status: approvalRequests.status, + decision: approvalRequests.decision, + createdAt: approvalRequests.createdAt, + decidedAt: approvalRequests.decidedAt, + completedAt: approvalRequests.completedAt, + action: approvalRequests.action, + }) + .from(approvalRequests) + .where( + and( + eq(approvalRequests.ownerUserId, ownerUserId), + sql`${approvalRequests.action}->>'threadId' = ${threadId}`, + ), + ), + deps.database + .select({ + key: workItems.key, + attempts: workItems.attempts, + payload: workItems.payload, + finishedAt: workItems.finishedAt, + }) + .from(workItems) + .where( + and( + eq(workItems.kind, PROACTIVE_TASK_KIND), + sql`${workItems.payload}->>'taskId' = ${task.id}`, + sql`${workItems.payload}->>'ownerUserId' = ${ownerUserId}`, + ), + ) + .orderBy(workItems.createdAt), + deps.database + .select({ lastMessageAt: channels.lastMessageAt }) + .from(channels) + .where(eq(channels.id, task.channelId)) + .limit(1), + deps.database + .select({ + key: workItems.key, + payload: workItems.payload, + finishedAt: workItems.finishedAt, + }) + .from(workItems) + .where( + and( + eq(workItems.kind, "person.response"), + sql`${workItems.payload}->>'ownerUserId' = ${ownerUserId}`, + sql`${workItems.payload}->'question'->>'actorId' = ${ownerUserId}`, + sql`${workItems.payload}->'question'->>'threadId' = ${threadId}`, + sql`(${workItems.payload}->'question'->>'channelId' is null + or ${workItems.payload}->'question'->>'channelId' = ${task.channelId})`, + ), + ), + ]); + const approvals: ReportApproval[] = rows.map((row) => ({ + id: row.id, + toolCallId: row.toolCallId, + status: row.status, + decision: row.decision, + createdAt: row.createdAt, + decidedAt: row.decidedAt, + completedAt: row.completedAt, + action: { + toolRef: row.action.toolRef, + effect: row.action.effect, + scope: row.action.scope, + args: row.action.args, + ...(row.action.target === undefined + ? {} + : { target: row.action.target }), + }, + })); + const resumed = + approvals.length === 0 + ? [] + : await deps.database + .select({ key: workItems.key, finishedAt: workItems.finishedAt }) + .from(workItems) + .where( + and( + eq(workItems.kind, "approval.resume"), + inArray( + workItems.key, + approvals.map((approval) => approval.id), + ), + ), + ); + return buildTaskReport({ + task, + messages, + approvals, + firstTurnEndedAt: turns[0]?.finishedAt ?? null, + resumedEndedAt: new Map( + resumed.flatMap((row) => + row.finishedAt ? [[row.key, row.finishedAt] as const] : [], + ), + ), + lastMessageAt: channel[0]?.lastMessageAt ?? null, + completedTurnIds: new Set([ + ...turns.flatMap((row) => + row.finishedAt && row.attempts > 0 + ? [ + taskExecutionId({ + taskId: task.id, + workKey: row.key, + attempt: row.attempts, + messageId: asString(row.payload.messageId) || undefined, + again: row.payload.again === true, + }), + ] + : [], + ), + ...resumed.flatMap((row) => + row.finishedAt ? [`${RESUME_PREFIX}${row.key}`] : [], + ), + ...answers.flatMap((row) => + row.finishedAt ? [`person-response:${row.key}`] : [], + ), + ]), + questionResponses: new Map( + answers.flatMap((row) => + typeof row.payload.response === "string" + ? [[`person-response:${row.key}`, row.payload.response] as const] + : [], + ), + ), + }); + }; +} diff --git a/server/src/tasks/routes.ts b/server/src/tasks/routes.ts new file mode 100644 index 0000000000..997d5f3e0d --- /dev/null +++ b/server/src/tasks/routes.ts @@ -0,0 +1,270 @@ +import { MAX_ATTACHMENTS_PER_MESSAGE } from "@openbot/shared/attachments"; +import type { DurableTaskReply } from "@openbot/shared/task-reply"; +import { Hono, type MiddlewareHandler } from "hono"; +import { bodyLimit } from "hono/body-limit"; +import { z } from "zod"; +import { AgentNotFoundError } from "../agents/profile-store"; +import type { AppVariables } from "../auth/guards"; +import { ProactiveRefusedError } from "../proactive/types"; +import type { TaskReportReader } from "./report"; +import type { TaskStore } from "./store"; +import { TaskNotFoundError } from "./types"; + +/** Replies to a task, and Try again: the proactive engine's `reply` and `again`. */ +export type TaskRetry = { + reply( + ownerUserId: string, + taskId: string, + input: DurableTaskReply, + ): Promise; + again( + ownerUserId: string, + taskId: string, + input?: { reply?: string; messageId?: string }, + ): Promise; +}; + +/** The longest reply a task takes. */ +const MAX_REPLY = 4000; +/** + * The id the browser drew the person's message under, so the message the run records is the same + * one and is not drawn twice. + */ +const messageId = z.string().regex(/^[A-Za-z0-9-]{8,64}$/); + +const againSchema = z + .object({ + reply: z.string().trim().max(MAX_REPLY).optional(), + messageId: messageId.optional(), + }) + .strict(); + +/** A reply carries words, files, or both, and the skills picked for it. Try again takes words only. */ +const replySchema = z + .object({ + reply: z.string().trim().max(MAX_REPLY).default(""), + messageId, + attachmentIds: z + .array(z.uuid()) + .max(MAX_ATTACHMENTS_PER_MESSAGE) + .optional(), + skillIds: z.array(z.string().min(1).max(200)).max(32).optional(), + }) + .strict() + .refine( + (input) => input.reply.length > 0 || (input.attachmentIds?.length ?? 0) > 0, + ); + +/** Starts a task the person asked for from the + on Tasks: the proactive engine's `startAsked`. */ +export type TaskIntake = { + start( + ownerUserId: string, + botId: string, + task: { title: string; instruction: string }, + ): Promise<{ id: string }>; +}; + +/** The longest ask the + takes, as `start_task` takes. */ +export const MAX_ASK = 4000; +/** How long a title made from the ask gets before it is cut at a word. */ +const TITLE_LENGTH = 80; + +/** + * A task's title from the person's ask when they gave none: the first sentence of its first line, + * with its full stop dropped, cut at a word with an ellipsis past `TITLE_LENGTH`. + */ +export function titleFrom(ask: string): string { + const line = + ask + .split("\n") + .map((part) => part.replace(/\s+/g, " ").trim()) + .find(Boolean) ?? ""; + // A sentence ends at a stop followed by a capital: "Sam. He", but not "no. 5521" or "it... then". + const sentence = line.split(/(?<=[^.][.!?])\s+(?=\p{Lu})/u)[0] ?? line; + const plain = sentence.replace(/(? TITLE_LENGTH / 2 ? cut.slice(0, space) : cut).replace(/[\s,;:.-]+$/, "")}…`; +} + +const askSchema = z + .object({ + botId: z.string().trim().min(1).max(200), + instruction: z.string().trim().min(1).max(MAX_ASK), + title: z.string().trim().min(1).max(160).optional(), + }) + .strict(); + +/** A request the routes refuse as it stands: the person can fix it and send it again. */ +export class TaskRefusedError extends Error { + constructor(message: string) { + super(message); + this.name = "TaskRefusedError"; + } +} + +/** `/api/tasks`: the person's own tasks, as Tasks and the task page read and steer them. */ +export function createTaskRoutes( + store: TaskStore, + requireUser: MiddlewareHandler<{ Variables: AppVariables }>, + options: { + /** Absent, replying and Try again are not served: there is nothing to run them. */ + retry?: TaskRetry | undefined; + /** Absent, the + is not served: there is nowhere to start a task. */ + intake?: TaskIntake | undefined; + /** Absent, a task's report is not served: there is no conversation history to read it from. */ + report?: TaskReportReader | undefined; + } = {}, +) { + const routes = new Hono<{ Variables: AppVariables }>(); + routes.use("*", requireUser, bodyLimit({ maxSize: 16_384 })); + routes.onError((error, context) => { + if (error instanceof TaskNotFoundError) + return context.json({ error: error.message }, 404); + // Asked for something the task's state refuses: Try again on one that did not fail, say. + if (error instanceof ProactiveRefusedError) + return context.json({ error: error.message }, 409); + if (error instanceof AgentNotFoundError) + return context.json( + { error: "Couldn't find that Bot. Reload the page." }, + 404, + ); + if (error instanceof TaskRefusedError) + return context.json({ error: error.message }, 400); + console.error( + JSON.stringify({ + type: "tasks-route-error", + error: error.name, + context: { route: context.req.path }, + timestamp: new Date().toISOString(), + }), + ); + return context.json({ error: "Couldn't load your tasks. Try again." }, 503); + }); + routes.get("/", async (context) => + context.json({ tasks: await store.list(context.var.actor.id) }), + ); + const intake = options.intake; + if (intake) + /* + * The + on Tasks, Start now: the ask opens a task in a conversation of its own with the Bot the + * person chose, and starts there, the path `start_task` takes. Any Bot of theirs, wherever it + * runs. Answers with the task as Tasks reads it. + */ + routes.post("/", async (context) => { + const owner = context.var.actor.id; + const ask = await readAsk(context.req.raw); + const opened = await intake.start(owner, ask.botId, { + title: ask.title, + instruction: ask.instruction, + }); + return context.json({ task: await store.get(owner, opened.id) }, 201); + }); + routes.get("/:id", async (context) => + context.json({ + task: await store.get(context.var.actor.id, context.req.param("id")), + }), + ); + const report = options.report; + if (report) + /* + * The task page's report: what was decided in it, what the Bot said as each turn ended, and the + * person's replies, worked out from its conversation on every read (tasks/report.ts). + */ + routes.get("/:id/report", async (context) => { + const owner = context.var.actor.id; + const task = await store.get(owner, context.req.param("id")); + try { + return context.json({ report: await report(owner, task) }); + } catch (error) { + console.error( + JSON.stringify({ + type: "task-report-error", + error: error instanceof Error ? error.name : "UnknownError", + timestamp: new Date().toISOString(), + }), + ); + return context.json( + { error: "Couldn't load this task's report. Try again." }, + 503, + ); + } + }); + const retry = options.retry; + if (retry) { + /* + * A reply in the task's conversation, to steer it. Taken onto the queue and answered at once, + * with the task as it now reads: its turn runs when the conversation is free, and until then + * the reply is in `queuedReplies`. + */ + routes.post("/:id/reply", async (context) => { + const owner = context.var.actor.id; + const id = context.req.param("id"); + const parsed = replySchema.safeParse( + await context.req.json().catch(() => null), + ); + if (!parsed.success) + throw new TaskRefusedError( + `A reply of at most ${MAX_REPLY} characters or an attachment, and its message id, are required.`, + ); + await store.get(owner, id); + await retry.reply(owner, id, parsed.data); + return context.json({ task: await store.get(owner, id) }, 202); + }); + /* + * Try again: the failed turn runs once more, in the task's own conversation. A reply typed on a + * failed task comes here too, and goes with the ask, so the run has the task and the person's + * words both. + */ + routes.post("/:id/again", async (context) => { + const owner = context.var.actor.id; + const id = context.req.param("id"); + const body = context.req + .header("content-type") + ?.includes("application/json") + ? await context.req.json().catch(() => null) + : {}; + const parsed = againSchema.safeParse(body ?? {}); + if (!parsed.success) + throw new TaskRefusedError( + `Say it in at most ${MAX_REPLY} characters.`, + ); + await retry.again(owner, id, { + ...(parsed.data.reply ? { reply: parsed.data.reply } : {}), + ...(parsed.data.messageId ? { messageId: parsed.data.messageId } : {}), + }); + return context.json({ task: await store.get(owner, id) }); + }); + } + routes.post("/:id/resolve", async (context) => + context.json({ + task: await store.resolve(context.var.actor.id, context.req.param("id")), + }), + ); + routes.post("/:id/reopen", async (context) => + context.json({ + task: await store.reopen(context.var.actor.id, context.req.param("id")), + }), + ); + return routes; +} + +/** The +'s ask, with its title worked out when the person gave none. */ +async function readAsk(request: Request) { + if (!request.headers.get("content-type")?.includes("application/json")) + throw new TaskRefusedError("Say what your Bot should do."); + const parsed = askSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) + throw new TaskRefusedError( + parsed.error.issues.some((issue) => issue.path[0] === "instruction") + ? `Say what your Bot should do, in at most ${MAX_ASK} characters.` + : "Choose one of your Bots.", + ); + const { botId, instruction } = parsed.data; + return { + botId, + instruction, + title: parsed.data.title ?? titleFrom(instruction), + }; +} diff --git a/server/src/tasks/store.ts b/server/src/tasks/store.ts new file mode 100644 index 0000000000..3e8c6e9f8b --- /dev/null +++ b/server/src/tasks/store.ts @@ -0,0 +1,787 @@ +import { randomUUID } from "node:crypto"; +import { and, desc, eq, inArray, isNull, or, type SQL, sql } from "drizzle-orm"; +import { + type PersonQuestion, + pendingPersonQuestion, + questionResponseMessage, +} from "../approvals/questions"; +import type { Database } from "../db/client"; +import { + approvalRequests, + channels, + intelligenceChannelMappings, + tasks, + workItems, +} from "../db/schema"; +import { + PROACTIVE_TASK_KIND, + type TaskInput, + taskInputSchema, + taskTurnEndingSchema, +} from "../proactive/engine"; +import { ProactiveRefusedError } from "../proactive/types"; +import { DEFAULT_MAX_ATTEMPTS } from "../work/queue"; +import { replyEnding, replyTail } from "./reply-ending"; +import { + type NewTask, + TASK_RUNS, + TaskNotFoundError, + type TaskRecord, + type TaskStatus, +} from "./types"; + +/* + * The person's tasks: work a Bot does in the background, each in a conversation of its own, so the + * conversation it was asked in stays one thread. The task page is its conversation, where the + * person can reply to steer it; Tasks lists them. + * + * WHAT A TASK IS DOING IS READ, NEVER STORED. The work queue already knows whether the task's turn is + * queued or running, the approval inbox whether it stopped for Allow once, and the question queue + * whether its Bot asked the person something. Copying any of that onto the task would be a second + * ledger that drifts from the first the moment one of them changes without it. The one thing only + * the person can say, that they are done with it, is the one thing kept: `resolvedAt`. It covers + * work accepted by then; a later durable turn supersedes it, without a separate reopening write. + * + * FAILING IS READ THE SAME WAY. A task's turn runs at most twice (`TASK_RUNS`): once, and once more + * if the first run threw or was cut off by a restart. A turn that has run out of runs stays on the + * queue unfinished, with its count and its last error, which is the queue's own terminal state + * (work/queue.ts). That row is the failure and its reason; nothing else records it. + */ + +/** The rule on `TaskStatus`, on its own, so it can be read and tested without a database. */ +export function taskStatus(state: { + resolved: boolean; + needsInput: boolean; + working: boolean; + failed?: boolean; +}): TaskStatus { + if (state.needsInput) return "needs_input"; + if (state.working) return "working"; + if (state.resolved) return "resolved"; + if (state.failed) return "failed"; + return "ready_for_review"; +} + +/** How long a task with no turn on the queue yet still reads as just started. */ +const STARTING_MS = 60_000; +/** A turn whose last run a crash or restart cut off. */ +const CUT_OFF = "the run was cut off partway through"; +/** A task whose turn never reached the queue. */ +const NEVER_STARTED = "it never started"; + +/** + * Why a task's turn couldn't finish, in words that read right after both the row's "Couldn't + * finish:" and the Bot's own "I couldn't finish this:", so never the Bot in the third person. Never + * the error itself: that is an operator's, kept on the queue's row, and can be a status code, a + * JSON body or a stack. Anything not recognised here is said generically. + */ +export function plainFailure(error: string | null | undefined): string { + if (!error) return CUT_OFF; + if (error === NEVER_STARTED) return NEVER_STARTED; + const stopped = /stopped after (\d+)s/.exec(error); + if (stopped) { + const minutes = Math.max(1, Math.round(Number(stopped[1]) / 60)); + return `the run took longer than ${minutes} minute${minutes === 1 ? "" : "s"}`; + } + const rules: [RegExp, string][] = [ + [/finished without saying anything/i, "the run ended without a reply"], + [/stopped to ask a question/i, "the run stopped to ask a question"], + [/no longer available/i, "this conversation is gone"], + [/nothing to start/i, "there was nothing to start"], + [/before (it|retries) could/i, "it failed before it could be tried again"], + [/\bpaused\b/i, "work is paused"], + [/allowed model/i, "the model isn't allowed here"], + [ + /lock|busy with another turn/i, + "the conversation was busy with another reply", + ], + [/cancel|abort/i, "the run was stopped"], + [ + /\b40[13]\b|authentication|api.?key/i, + "the model provider turned the request away", + ], + [ + /fetch failed|ECONN|ETIMEDOUT|ENOTFOUND|socket hang up|unavailable|overloaded|\b50[234]\b/i, + "a service didn't answer", + ], + ]; + return ( + rules.find(([pattern]) => pattern.test(error))?.[1] ?? + "something went wrong during the run" + ); +} + +/** How long the person's own words may run as a task's next step. */ +const QUOTE_MAX = 120; + +/** + * A task's next step, from the last line said in its conversation: the Bot's words end on what comes + * next (`replyTail`); the person's are what the Bot is now working on, quoted. + */ +export function nextStepOf( + said: string | null | undefined, + byBot: boolean, +): string | null { + const text = said?.trim(); + if (!text) return null; + if (byBot) return replyTail(text); + const words = Array.from(text); + return `“${words.length > QUOTE_MAX ? `${words.slice(0, QUOTE_MAX - 1).join("")}…` : text}”`; +} + +type Row = typeof tasks.$inferSelect; +/** What a task's conversation is doing, and its thread, as one read of the ledgers found them. */ +type TaskState = { + /** The person's completion, unless genuinely new durable work superseded it. */ + resolvedAt: Date | null; + /** What the turn its status is read from was asked: what Try again asks again. */ + turnInput: Record | null; + /** How many turns of its own it has had, which keys Try again so two at once run once. */ + turns: number; + queuedReplies: { messageId: string; text: string }[]; + answeredQuestionIds: Set; + needsInput: boolean; + working: boolean; + threadId: string | null; + /** The plain reason, when its turn ran out of runs, and when. */ + failure: string | null; + failedAt: Date | null; + /** See `TaskRecord.next`. */ + next: string | null; +}; + +/** A JSON field of a work item's payload, as text. */ +const payload = (field: string) => sql`${workItems.payload}->>${field}`; +/* + * The same expressions as `work_items_owner_task_idx`, so the reads use it however their statement + * is planned. Through `payload` the field is a parameter, which a plan made once for every value + * cannot match to the index. See work.ts. + */ +const workOwner = sql`(${workItems.payload}->>'ownerUserId')`; +const turnTask = sql`(${workItems.payload}->>'taskId')`; +/** The conversation an approval was raised in, as text. */ +const approvalThread = sql`${approvalRequests.action}->>'threadId'`; + +export type TaskStore = ReturnType; +type TaskTransaction = Parameters[0]>[0]; + +/** Every read and write is the owner's: another person's task is the same as no task. */ +export function createTaskStore( + database: Database, + options: { now?: () => Date } = {}, +) { + const now = options.now ?? (() => new Date()); + const owned = (owner: string, id: string) => + and(eq(tasks.ownerUserId, owner), eq(tasks.id, id)); + + /** + * What each task is doing, from the ledgers that say so. A handful of queries for the whole list, + * whatever its length, each narrowed to the person first. + */ + async function stateOf( + owner: string, + rows: readonly Row[], + ): Promise> { + const state = new Map( + rows.map((row) => [ + row.id, + { + resolvedAt: row.resolvedAt, + turnInput: null, + turns: 0, + queuedReplies: [], + answeredQuestionIds: new Set(), + needsInput: false, + working: false, + threadId: null, + failure: null, + failedAt: null, + next: null, + }, + ]), + ); + if (rows.length === 0) return state; + const channelIds = rows.map((row) => row.channelId); + const taskOfChannel = new Map(rows.map((row) => [row.channelId, row.id])); + // The last line said in each task's conversation, for its next step. + const lastSaid = await database + .select({ + id: channels.id, + lastMessage: channels.lastMessage, + byBot: sql`${channels.lastMessageAgentId} is not null`, + sourceId: channels.lastMessageSourceId, + spoken: sql`${channels.lastMessageAt} is not null`, + afterResolution: sql`coalesce(${channels.lastMessageAt} > ${tasks.resolvedAt}, true)`, + }) + .from(channels) + .innerJoin(tasks, eq(tasks.channelId, channels.id)) + .where(inArray(channels.id, channelIds)); + // Tasks whose conversation has had anything said in it, which a turn starts by doing. + const spokenIn = new Set( + lastSaid.flatMap((said) => + said.spoken ? [taskOfChannel.get(said.id) ?? ""] : [], + ), + ); + for (const said of lastSaid) { + const entry = state.get(taskOfChannel.get(said.id) ?? ""); + // The ask a turn starts on is the task itself, not a step: "Just started" says that better. + const ask = !said.byBot && (said.sourceId?.endsWith(":input") ?? false); + if (entry && !ask) entry.next = nextStepOf(said.lastMessage, said.byBot); + } + const threads = await database + .select({ + channelId: intelligenceChannelMappings.channelId, + threadId: intelligenceChannelMappings.threadId, + }) + .from(intelligenceChannelMappings) + .where( + and( + eq(intelligenceChannelMappings.userId, owner), + inArray(intelligenceChannelMappings.channelId, channelIds), + ), + ); + const channelOfThread = new Map( + threads.map((row) => [row.threadId, row.channelId]), + ); + const threadIds = [...channelOfThread.keys()]; + for (const row of threads) { + const taskId = taskOfChannel.get(row.channelId); + const entry = taskId ? state.get(taskId) : undefined; + if (entry) entry.threadId = row.threadId; + } + const mark = ( + channelId: string | null | undefined, + field: "needsInput" | "working", + ) => { + const taskId = channelId ? taskOfChannel.get(channelId) : undefined; + const entry = taskId ? state.get(taskId) : undefined; + if (entry) entry[field] = true; + }; + const inThreads = (expression: SQL) => + threadIds.length > 0 ? inArray(expression, threadIds) : sql`false`; + + // Waiting on the person: an action its turn paused on for Allow once, or a question it asked. + const [approvals, questions] = await Promise.all([ + database + .select({ threadId: approvalThread }) + .from(approvalRequests) + .where( + and( + eq(approvalRequests.ownerUserId, owner), + eq(approvalRequests.status, "pending"), + inThreads(approvalThread), + ), + ), + database + .select({ + key: workItems.key, + channelId: payload("channelId"), + threadId: payload("threadId"), + }) + .from(workItems) + .where( + and( + eq(workItems.kind, "person.question"), + isNull(workItems.finishedAt), + sql`${payload("actorId")} = ${owner}`, + or( + inArray(payload("channelId"), channelIds), + inThreads(payload("threadId")), + ), + ), + ), + ]); + for (const row of approvals) + mark(channelOfThread.get(row.threadId), "needsInput"); + + /* + * Its turns, in creation order for the queued reply list. The effective outcome follows when + * each turn finished or failed: an older insert may commit and run after a newer turn finished. + * The proactive engine owns task turns; an older one may name its suggestion. Question-card + * answers are continuations in the same conversation, owned by the person-response worker. + */ + const suggestionIds = rows.flatMap((row) => + row.suggestionId ? [row.suggestionId] : [], + ); + // The same ledger timestamps order task executions and question-card continuations. + const turnState = { + key: workItems.key, + acceptedAt: sql`extract(epoch from ${workItems.createdAt})`.mapWith( + Number, + ), + // Compare in SQL so a same-millisecond acceptance still follows the exact completion. + afterResolution: sql`coalesce(${workItems.createdAt} > ${tasks.resolvedAt}, false)`, + // A crash fails at lease expiry, not its last heartbeat. Finished rows use their actual + // completion; released rows use the release timestamp. Keep PostgreSQL's sub-ms order. + outcomeAt: sql`extract(epoch from coalesce( + ${workItems.finishedAt}, + case when ${workItems.claimedBy} is not null then ${workItems.leaseUntil} end, + ${workItems.updatedAt} + ))`.mapWith(Number), + attempts: workItems.attempts, + live: sql`coalesce(${workItems.leaseUntil} > now(), false)`, + finished: sql`${workItems.finishedAt} is not null`, + lastError: workItems.lastError, + claimed: sql`${workItems.claimedBy} is not null`, + updatedAt: workItems.updatedAt, + leaseUntil: workItems.leaseUntil, + }; + const responseQuestion = sql`${workItems.payload}->'question'`; + const responseThread = sql`${responseQuestion}->>'threadId'`; + const responseChannel = sql`${responseQuestion}->>'channelId'`; + const [turns, resuming, responses] = await Promise.all([ + database + .select({ + ...turnState, + input: workItems.payload, + reply: payload("reply"), + followup: payload("followup"), + questionIds: sql< + string[] | null + >`${workItems.payload}->'questionIds'`, + messageId: payload("messageId"), + taskId: turnTask, + suggestionId: payload("suggestionId"), + }) + .from(workItems) + .leftJoin(tasks, eq(tasks.id, turnTask)) + .where( + and( + eq(workItems.kind, PROACTIVE_TASK_KIND), + // Every task turn names the person it runs as, from before tasks too. + eq(workOwner, owner), + or( + inArray( + turnTask, + rows.map((row) => row.id), + ), + suggestionIds.length > 0 + ? and( + sql`${turnTask} is null`, + inArray(payload("suggestionId"), suggestionIds), + ) + : undefined, + ), + ), + ) + .orderBy(desc(workItems.createdAt), desc(workItems.key)), + database + .select({ threadId: approvalThread }) + .from(workItems) + .innerJoin( + approvalRequests, + eq(approvalRequests.id, payload("approvalId")), + ) + .where( + and( + eq(workItems.kind, "approval.resume"), + isNull(workItems.finishedAt), + eq(approvalRequests.ownerUserId, owner), + inThreads(approvalThread), + sql`(${workItems.attempts} < ${DEFAULT_MAX_ATTEMPTS} or ${workItems.leaseUntil} > now())`, + ), + ), + database + .select({ + ...turnState, + taskId: tasks.id, + question: responseQuestion, + response: payload("response"), + }) + .from(workItems) + .innerJoin( + intelligenceChannelMappings, + and( + eq(intelligenceChannelMappings.userId, owner), + eq(intelligenceChannelMappings.threadId, responseThread), + ), + ) + .innerJoin( + tasks, + and( + eq(tasks.ownerUserId, owner), + eq(tasks.channelId, intelligenceChannelMappings.channelId), + ), + ) + .where( + and( + eq(workItems.kind, "person.response"), + eq(workOwner, owner), + sql`${responseQuestion}->>'actorId' = ${owner}`, + inArray( + tasks.id, + rows.map((row) => row.id), + ), + or(isNull(responseChannel), eq(responseChannel, tasks.channelId)), + ), + ), + ]); + const taskOfSuggestion = new Map( + rows.flatMap((row) => + row.suggestionId ? [[row.suggestionId, row.id] as const] : [], + ), + ); + // Whether a turn's reply asked, read whole as it finished, by the id it was recorded under. + const replyAsks = new Map(); + for (const turn of turns) { + const ending = taskTurnEndingSchema.safeParse(turn.input.ending); + if (ending.success) + replyAsks.set(ending.data.messageId, ending.data.asks); + } + const taskTurns = [ + ...turns.map((turn) => ({ ...turn, own: true, maxAttempts: TASK_RUNS })), + ...responses.map(({ question, response, ...turn }) => { + const message = questionResponseMessage( + question, + response, + `person-response:${turn.key}`, + ); + return { + ...turn, + own: false, + // The person-response worker accepts one attempt, unlike a task's own turns. + maxAttempts: 1, + suggestionId: null, + followup: "true", + reply: response, + messageId: message.id, + questionIds: [turn.key], + // Tried again, the answer is carried on as a reply of the person's, in its own words. + input: { + text: message.content, + followup: true, + questionIds: [turn.key], + inputId: message.id, + reply: response, + } as Record, + }; + }), + ].sort((a, b) => b.acceptedAt - a.acceptedAt || b.key.localeCompare(a.key)); + const latestOutcome = new Map(); + const unfinished = new Map(); + for (const turn of taskTurns) { + const taskId = + turn.taskId ?? taskOfSuggestion.get(turn.suggestionId ?? ""); + // Acceptance answers only the captured questions. Keep that fact across exhausted turns + // and later turns that did not carry the original reply's snapshot. + if (taskId && turn.followup === "true") + for (const id of turn.questionIds ?? []) + state.get(taskId)?.answeredQuestionIds.add(id); + if (taskId) { + const entry = state.get(taskId); + if (entry && turn.taskId) { + if (turn.own) entry.turns += 1; + if (turn.afterResolution) entry.resolvedAt = null; + } + const latest = latestOutcome.get(taskId); + // Ties retain creation/key order, but insertion order never hides a later terminal turn. + if (!latest || turn.outcomeAt > latest.outcomeAt) + latestOutcome.set(taskId, turn); + } + if ( + taskId && + !turn.finished && + (turn.live || turn.attempts < turn.maxAttempts) && + !unfinished.has(taskId) + ) + unfinished.set(taskId, turn); + if ( + taskId && + turn.reply && + turn.messageId && + !turn.finished && + !turn.live && + turn.attempts < turn.maxAttempts + ) + state.get(taskId)?.queuedReplies.unshift({ + messageId: turn.messageId, + text: turn.reply, + }); + } + for (const question of questions) { + const channelId = + question.channelId ?? channelOfThread.get(question.threadId ?? ""); + const taskId = channelId ? taskOfChannel.get(channelId) : undefined; + if (!taskId || !state.get(taskId)?.answeredQuestionIds.has(question.key)) + mark(channelId, "needsInput"); + } + const at = now().getTime(); + for (const row of rows) { + const entry = state.get(row.id); + if (!entry) continue; + // Any queued/live turn takes priority, including one whose older insert committed late. + const turn = unfinished.get(row.id) ?? latestOutcome.get(row.id); + entry.turnInput = turn?.input ?? null; + if (!turn) { + /* + * Opened, and its turn not queued yet: the moment between the two writes that start a task. + * Past that, it never will be, so it failed to start rather than reading Working for ever. + * + * Unless something was said in its conversation, as a turn's first step is to say the ask + * there. Then it did start, and its turns have since left the queue: it reads from what was + * said, so clearing old turns never turns every old task into one that never started. + */ + if (at - row.createdAt.getTime() < STARTING_MS) entry.working = true; + else if (!spokenIn.has(row.id)) { + entry.failure = plainFailure(NEVER_STARTED); + entry.failedAt = new Date(row.createdAt.getTime() + STARTING_MS); + } + continue; + } + if (turn.finished) continue; + /* + * Working while queued, running, or below its worker's attempt cap after a throw or restart. + * Question-card answers have one attempt; a task's own turns have two. + */ + if (turn.live || turn.attempts < turn.maxAttempts) { + entry.working = true; + continue; + } + /* + * It stays failed until a turn of its own finishes. Anything else said in its conversation + * since is not that turn: the task page sends a reply on a failed task as Try again. + * + * Still claimed means its last run was cut off, not let go after a throw, whatever error an + * earlier run left on the row; it failed when that lease ran out. + */ + const cutOff = turn.claimed && turn.leaseUntil !== null; + entry.failure = plainFailure(cutOff ? null : turn.lastError); + entry.failedAt = + cutOff && turn.leaseUntil ? turn.leaseUntil : turn.updatedAt; + } + for (const row of resuming) + mark(channelOfThread.get(row.threadId), "working"); + for (const said of lastSaid) { + const entry = state.get(taskOfChannel.get(said.id) ?? ""); + /* + * The line its conversation keeps is cut short, so a long closing question loses its question + * mark there. A task's turn says whether its reply asked (`TaskTurnEnding`). Anything else + * said there, and a reply from before turns said so, is read from the line. + */ + const asks = + replyAsks.get(said.sourceId ?? "") ?? + replyEnding(said.lastMessage).asks; + if ( + entry && + said.byBot && + asks && + !entry.working && + !entry.failure && + !entry.resolvedAt && + // New work must not resurrect a final question the person already dismissed. + said.afterResolution + ) + entry.needsInput = true; + } + return state; + } + + function recordOf(row: Row, state: TaskState | undefined): TaskRecord { + const resolvedAt = state ? state.resolvedAt : row.resolvedAt; + const status = taskStatus({ + resolved: resolvedAt !== null, + needsInput: state?.needsInput ?? false, + working: state?.working ?? false, + failed: (state?.failure ?? null) !== null, + }); + const failedOrDone = status === "failed" || status === "resolved"; + return { + id: row.id, + botId: row.botId, + channelId: row.channelId, + threadId: state?.threadId ?? null, + title: row.title, + why: row.why, + origin: row.origin, + suggestionId: row.suggestionId, + sourceApp: row.sourceApp, + sourceLink: row.sourceLink, + status, + // Kept while it is marked done, too: Reopen brings the failure back, and so may a reply. + failure: failedOrDone ? (state?.failure ?? null) : null, + failedAt: failedOrDone ? (state?.failedAt?.toISOString() ?? null) : null, + next: state?.next ?? null, + queuedReplies: state?.queuedReplies ?? [], + answeredQuestionIds: [...(state?.answeredQuestionIds ?? [])], + resolvedAt: resolvedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } + + async function oneWithState(owner: string, id: string) { + const [row] = await database + .select() + .from(tasks) + .where(owned(owner, id)) + .limit(1); + if (!row) throw new TaskNotFoundError(); + const state = (await stateOf(owner, [row])).get(id); + if (!state) throw new TaskNotFoundError(); + return { record: recordOf(row, state), state }; + } + + async function one(owner: string, id: string): Promise { + return (await oneWithState(owner, id)).record; + } + + async function setResolved(owner: string, id: string, resolved: boolean) { + // The same clock and precision as durable acceptance, across every server replica. + const at = sql`now()`; + const [row] = await database + .update(tasks) + .set({ resolvedAt: resolved ? at : null, updatedAt: at }) + .where(owned(owner, id)) + .returning({ id: tasks.id }); + if (!row) throw new TaskNotFoundError(); + return one(owner, id); + } + + return { + /** + * Records a task opened in a conversation made for it. On the transaction that made the + * conversation, so neither is ever there without the other. + */ + async create( + input: NewTask, + executor: Database | TaskTransaction = database, + ): Promise { + const at = now(); + const [row] = await executor + .insert(tasks) + .values({ id: randomUUID(), ...input, createdAt: at, updatedAt: at }) + .returning(); + if (!row) throw new Error("The task was not saved."); + return recordOf(row, undefined); + }, + /** + * The person's questions waiting in a task's conversation, less any a reply already answered: + * the ones a reply taken now answers. + */ + async pendingQuestions(owner: string, id: string): Promise { + const task = await one(owner, id); + const rows = await database + .select({ key: workItems.key }) + .from(workItems) + .where( + and( + pendingPersonQuestion(owner), + sql`${workItems.payload}->>'mode' = 'completed_question'`, + or( + sql`${workItems.payload}->>'channelId' = ${task.channelId}`, + task.threadId + ? sql`${workItems.payload}->>'threadId' = ${task.threadId}` + : sql`false`, + ), + ), + ); + return rows.map((row) => row.key); + }, + /** Marks the person's questions answered, by a reply that has now run. */ + async answerQuestions(owner: string, ids: readonly string[]) { + if (ids.length === 0) return; + await database + .update(workItems) + .set({ finishedAt: sql`now()`, updatedAt: sql`now()` }) + .where( + and( + eq(workItems.kind, "person.question"), + inArray(workItems.key, [...ids]), + sql`${workItems.payload}->>'actorId' = ${owner}`, + isNull(workItems.finishedAt), + ), + ); + }, + /** Whether this conversation of the person's is a task's own. */ + async isTaskConversation(owner: string, channelId: string) { + const [row] = await database + .select({ id: tasks.id }) + .from(tasks) + .where( + and(eq(tasks.ownerUserId, owner), eq(tasks.channelId, channelId)), + ) + .limit(1); + return row !== undefined; + }, + /** Whether this thread of the person's is a task's own conversation. */ + async isTaskThread(owner: string, threadId: string) { + const [row] = await database + .select({ id: tasks.id }) + .from(tasks) + .innerJoin( + intelligenceChannelMappings, + and( + eq(intelligenceChannelMappings.channelId, tasks.channelId), + eq(intelligenceChannelMappings.userId, owner), + ), + ) + .where( + and( + eq(tasks.ownerUserId, owner), + eq(intelligenceChannelMappings.threadId, threadId), + ), + ) + .limit(1); + return row !== undefined; + }, + /** Where a task lives, without working out what it is doing: for running its turn. */ + async locate( + owner: string, + id: string, + ): Promise<{ channelId: string; botId: string } | null> { + const [row] = await database + .select({ channelId: tasks.channelId, botId: tasks.botId }) + .from(tasks) + .where(owned(owner, id)) + .limit(1); + return row ?? null; + }, + /** + * What a failed task's turn was asked to do, to run it again, and how many turns it has had: + * the ask its failed turn carried, or, for one never queued, its suggestion or its title. + * Refused unless it failed, so Try again never starts a second run beside a live one. + * + * A task marked done after it failed is taken too. Reading what to ask leaves it done; only the + * rerun, once it is on the queue, makes it not done. + */ + async failedTurn( + owner: string, + id: string, + ): Promise<{ ask: TaskInput; turns: number }> { + const { record: task, state } = await oneWithState(owner, id); + const failed = + task.status === "failed" || + (task.status === "resolved" && task.failure !== null); + if (!failed) + throw new ProactiveRefusedError( + "Only a task that couldn't finish can be tried again.", + ); + const input = taskInputSchema.parse(state.turnInput ?? {}); + const suggestionId = input.suggestionId ?? task.suggestionId; + // An empty text is a reply of files alone, and is kept. Only a missing ask falls back. + const ask = + input.text !== undefined + ? input + : { + ...input, + ...(suggestionId ? { suggestionId } : { text: task.title }), + }; + return { ask, turns: state.turns }; + }, + /** The owner's tasks, newest first. No cap on how many there are; a page is what a list shows. */ + async list(owner: string, limit = 200): Promise { + const rows = await database + .select() + .from(tasks) + .where(eq(tasks.ownerUserId, owner)) + .orderBy(desc(tasks.createdAt), desc(tasks.id)) + .limit(limit); + const state = await stateOf(owner, rows); + return rows.map((row) => recordOf(row, state.get(row.id))); + }, + get: one, + /** The person is done with it. Its conversation stays, and Reopen brings it back. */ + resolve: (owner: string, id: string) => setResolved(owner, id, true), + reopen: (owner: string, id: string) => setResolved(owner, id, false), + }; +} diff --git a/server/src/tasks/threads.ts b/server/src/tasks/threads.ts new file mode 100644 index 0000000000..92373eb369 --- /dev/null +++ b/server/src/tasks/threads.ts @@ -0,0 +1,243 @@ +import { + frameTaskInput, + readTaskBrief, + TASK_ENDING, +} from "@openbot/shared/task-brief"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; +import { ThreadLockBusyError } from "@openbot/toolkit/lease"; +import { + HEADLESS_WAITING_FALLBACK, + type TurnRunner, +} from "@openbot/toolkit/run-turn"; +import type { AgentActor } from "../agents/profile-types"; +import { PERSON_INITIATOR } from "../audit"; +import type { ChannelStore } from "../channels/routes"; +import type { Database } from "../db/client"; +import type { + ProactiveEngineDeps, + ProactiveScope, + TaskThreads, + TaskTurnEnding, +} from "../proactive/engine"; +import type { TaskReplyContextReader } from "./reply-context"; +import { replyEnding } from "./reply-ending"; +import { plainFailure, type TaskStore } from "./store"; + +/* + * The two halves of a task the proactive engine leaves to the server's assembly: making its + * conversation, and running its turn there. + */ + +/** + * Opens a task in a new conversation with the Bot, and finds that conversation again to run it. + * + * A NEW CONVERSATION EVERY TIME, never the person's own with the Bot, so the work stays out of the + * conversation it was asked in. The channel store leaves task conversations out when it looks for + * that one (`direct`), and the roster marks them (`taskId`), so the app never mistakes one for the + * person's own. + */ +export function createTaskThreads(deps: { + database: Pick; + store: Pick< + TaskStore, + "create" | "locate" | "failedTurn" | "pendingQuestions" | "answerQuestions" + >; + channels: Pick; + actorFor: (ownerUserId: string) => Promise; + /** The conversation as a turn needs it, or null once the person cannot reach it or its Bot. */ + scopeFor: ( + ownerUserId: string, + channelId: string, + agentId: string, + ) => Promise; +}): TaskThreads { + async function scope(ownerUserId: string, taskId: string) { + const task = await deps.store.locate(ownerUserId, taskId); + return task ? deps.scopeFor(ownerUserId, task.channelId, task.botId) : null; + } + return { + pendingQuestions: (ownerUserId, taskId) => + deps.store.pendingQuestions(ownerUserId, taskId), + answerQuestions: (ownerUserId, ids) => + deps.store.answerQuestions(ownerUserId, ids), + async open(input) { + const actor = await deps.actorFor(input.ownerUserId); + /* + * One transaction for both. `direct` tells a task's conversation by its task row, so a + * conversation committed before its row could be taken for the person's own with the Bot by a + * lookup that ran in between. + */ + return deps.database.transaction(async (transaction) => { + const channel = await deps.channels.create( + actor, + [input.agentId], + transaction, + ); + const task = await deps.store.create( + { + ownerUserId: input.ownerUserId, + botId: input.agentId, + channelId: channel.id, + title: input.title, + why: input.why, + origin: input.origin, + suggestionId: input.suggestionId, + sourceApp: input.sourceApp, + sourceLink: input.sourceLink, + }, + transaction, + ); + return { id: task.id, channelId: task.channelId, title: task.title }; + }); + }, + scope, + failedTurn: (ownerUserId, taskId) => + deps.store.failedTurn(ownerUserId, taskId), + async settled(ownerUserId, taskId) { + const found = await scope(ownerUserId, taskId); + if (found) await deps.channels.signalBusy(found.threadId, false); + }, + }; +} + +/** + * What a task's conversation says while its turn waits on the person. The line becomes the task's + * next step on Tasks, so a turn stopped with no words of its own is named for what it waits on, + * never by the toolkit's "The turn is waiting for human input." + */ +function waitingLine(suspension: HeadlessToolSuspension): string { + if (suspension.message !== HEADLESS_WAITING_FALLBACK) + return suspension.message; + return suspension.waiting.kind === "approval" + ? "Waiting for you to allow a step." + : "Waiting for your answer."; +} + +/** + * A task's turn: the person's ask as their message in the task's conversation, a person-started turn + * through the runner every started task uses, and the Bot's reply recorded, which is what tells an + * open conversation to read the thread again. + * + * The conversation reads as busy from the moment it starts, since no browser streams this turn. The + * engine says it is no longer busy once the turn is off the queue (`TaskThreads.settled`). + * + * STOPPING FOR THE PERSON IS NOT FAILING. A turn that reaches Allow once, or asks a question, ends + * with the request waiting on them; answering it carries the turn on (approvals/resume-sweep.ts). So + * it is recorded as the Bot's line and the turn is done, rather than thrown as an error the queue + * would log and hold. + * + * FAILING ON ITS LAST RUN IS SAID. A turn that throws is thrown on, for the queue to hold and retry. + * On its last run (`final`) the Bot's line says it couldn't finish and why, in plain words, so the + * conversation does not end on the person's ask with nothing after it. A turn refused the + * conversation's lock on its first ask never ran, and is put back on the queue without a word. + */ +export function createTaskTurn(deps: { + channels: Pick; + actorFor: (ownerUserId: string) => Promise; + runTurn: TurnRunner; + /** Resolves a reply's files and skills. Absent, a reply with either fails. */ + replyContext?: Pick; +}): ( + ...turn: Parameters +) => Promise { + return async (scope, input) => { + const actor = await deps.actorFor(scope.ownerUserId); + const record = (text: string, agentId: string | null, id: string) => + deps.channels.recordActivity( + actor, + scope.channelId, + { text, agentId, at: new Date() }, + { id }, + ); + // The conversation's line is the person's ask, never the frame the model reads around it. + const brief = readTaskBrief(input.text); + await record( + input.acceptedReply?.text ?? brief ?? (input.text || "Sent attachments"), + null, + brief === null ? input.runId : `${input.runId}:input`, + ); + await deps.channels.signalBusy(scope.threadId, true).catch(() => {}); + try { + const context = + input.attachmentIds?.length || input.skillIds?.length + ? await (deps.replyContext?.resolve(scope, input) ?? + Promise.reject( + new Error("Files and skills are unavailable for this task."), + )) + : { attachments: [], skillInstructions: [] }; + /* + * A reply carries who sent it and what they said in its message, so the reply reads the same + * however many times it is run. The runner's instruction and any files are the person's input + * as it was. + */ + const text = input.acceptedReply + ? frameTaskInput(input.text, input.acceptedReply) + : input.text; + const content = context.attachments.length + ? [ + ...(text ? [{ type: "text" as const, text }] : []), + ...context.attachments, + ] + : text; + const result = await deps.runTurn({ + ...scope, + routineId: input.runId, + instruction: input.text, + userMessage: { id: input.runId, role: "user", content }, + systemMessages: [ + // One id for every turn of the task, so its conversation holds this once. + { id: "task:ending", role: "system" as const, content: TASK_ENDING }, + ...context.skillInstructions.map((instructions, index) => ({ + id: `${input.runId}:skill:${index}`, + role: "system" as const, + content: instructions, + })), + ], + initiator: PERSON_INITIATOR, + signal: input.signal, + }); + /* + * Recorded as how the reply ends, its next step, rather than how it starts: Tasks shows this + * line after the task's status (`TaskRecord.next`), and a conversation's preview keeps only + * its first 200 characters, which for a long reply would be its opening. + * + * Whether it asks is read here, from the whole reply, and answered for the turn's work item: + * the line recorded is cut short, and a long closing question loses its question mark there. + */ + if (result.replyText) { + const ending = replyEnding(result.replyText); + const messageId = `${input.runId}:reply`; + await record(ending.tail ?? result.replyText, scope.agentId, messageId); + return { messageId, asks: ending.asks }; + } + } catch (error) { + if (!(error instanceof HeadlessToolSuspension)) { + if (input.final && !(error instanceof ThreadLockBusyError)) + await record( + `I couldn't finish this: ${plainFailure(error instanceof Error ? error.message : String(error))}.`, + scope.agentId, + `${input.runId}:failed`, + ).catch(() => {}); + throw error; + } + await record(waitingLine(error), scope.agentId, `${input.runId}:waiting`); + } + }; +} + +/** + * Carries on a turn that waited on the person: Allow once, an answer to a question card, a sign-in. + * One in a task's own conversation is that task's turn, so it is built as a task's turn is + * (`taskTurn`) and its Bot is not offered `start_task` there (tasks/tools.ts). Anywhere else it is + * built as `otherwise` builds it. + */ +export function createContinuationRunner(deps: { + isTaskThread: (ownerUserId: string, threadId: string) => Promise; + taskTurn: TurnRunner; + otherwise: TurnRunner; +}): TurnRunner { + return async (input) => + (await deps.isTaskThread(input.ownerUserId, input.threadId)) + ? deps.taskTurn(input) + : deps.otherwise(input); +} diff --git a/server/src/tasks/tools.ts b/server/src/tasks/tools.ts new file mode 100644 index 0000000000..d9cf027577 --- /dev/null +++ b/server/src/tasks/tools.ts @@ -0,0 +1,165 @@ +import { START_TASK_TOOL } from "@openbot/shared/task-brief"; +import type { GrantedTool } from "@openbot/toolkit/granted-tool"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import type { AuditInitiator } from "../audit"; +import type { Database } from "../db/client"; +import { agents } from "../db/schema"; +import type { AskedTask, OpenedTask } from "../proactive/engine"; + +export { START_TASK_TOOL }; +/** The whole ask, in the person's words or a faithful restatement. Room for a long paragraph. */ +export const MAX_INSTRUCTION = 4000; +/** + * How many tasks one turn may start. Enough for a person who asks for several things at once; a + * page or message the Bot read can't use one turn to start dozens of headless turns. + */ +export const MAX_TASKS_PER_TURN = 5; + +/** Opens a task in a conversation of its own and starts it: the proactive engine's `startAsked`. */ +export type TaskStarter = (input: { + ownerUserId: string; + agentId: string; + task: AskedTask; +}) => Promise; + +/** + * Whether OpenBot runs this Bot itself, on its own runtime, rather than a remote agent framework + * behind an endpoint. Only those hand work to a task from a conversation: a remote Bot composes its + * own turn at somebody else's server, so it is not told to, and its tasks start from the + on Tasks + * and from its suggestions instead. + */ +export function builtInBots(database: Pick) { + return async (botId: string): Promise => { + const [row] = await database + .select({ type: agents.type }) + .from(agents) + .where(eq(agents.id, botId)) + .limit(1); + return row?.type === "built_in"; + }; +} + +const startParameters = z + .object({ + title: z + .string() + .trim() + .min(1) + .max(160) + .describe( + "The task's name on its card: a short imperative, e.g. 'Draft a reply to Rosa about Oat Satin'.", + ), + instruction: z + .string() + .trim() + .min(1) + .max(MAX_INSTRUCTION) + .describe( + "The whole ask, in the person's words or a faithful restatement, with every detail they gave. The task starts from this alone.", + ), + why: z + .string() + .trim() + .min(1) + .max(600) + .optional() + /* + * Shown on the task's report as its why, so it is the person's, never the Bot's: a Bot told + * only "why it matters" filled it with its own reason for making a task ("a multi-step planning + * request that benefits from a dedicated task"). + */ + .describe( + "Why it matters to the person or by when, only as they said it and in their words; leave it out when they said neither. Never your own reason for making this a task, such as that it has several steps.", + ), + source: z + .string() + .trim() + .min(1) + .max(80) + .optional() + .describe( + "The app it came from, as the person would say it: 'Gmail', 'Google Calendar'. Leave out when it came from the conversation itself; never what kind of request it is or why it is a task.", + ), + link: z + .string() + .trim() + .max(1000) + .refine( + (value) => /^https?:\/\//i.test(value) && URL.canParse(value), + "Links must be http or https URLs.", + ) + .optional() + .describe( + "The link to the email or event it came from, exactly as you read it. Leave out when you have none; never a page to use for the work, and never one you made up.", + ), + }) + .strict(); + +/** + * `start_task`: anything bigger than a quick answer, handed off from a conversation to a task of its + * own. + * + * The conversation stays one thread. The work happens in the task's own conversation with the same + * Bot, which the person opens from the card this leaves and can reply in to steer; the conversation + * it was asked in only says "On it". Opening the task and starting its turn is the proactive engine's + * (`startAsked`), the path the + on Tasks takes, so both run, wait and finish the same way as a + * started suggestion. + * + * Nothing leaves the deployment here: the turn it starts is the person's own, made on their behalf in + * their words, and anything it then tries to send meets the approval gate as any turn of theirs does. + * + * So it is offered only in a conversation a person started. A routine, a responsibility, a wake-up or + * a hand-off acting on what it read would otherwise start a turn that runs as the person, past every + * rule written for unattended runs and credited to them on the trail. A task's own turn is never + * offered it either: there the work is done, not handed on again. + */ +export async function taskTools(options: { + actorId: string; + botId: string; + initiator: AuditInitiator; + start: TaskStarter; + /** Whether OpenBot runs this Bot itself. See `builtInBots`. */ + builtIn: (botId: string) => Promise; + /** This turn is a task's own. See `AgentResolutionContext.task`. */ + inTask?: boolean; +}): Promise { + if (options.initiator.kind !== "person" || options.inTask) return []; + if (!(await options.builtIn(options.botId))) return []; + // Tools are built for each turn, so this counts one turn's starts. + let started = 0; + return [ + { + name: START_TASK_TOOL, + ref: `tasks/${START_TASK_TOOL}`, + description: [ + "Hand work to a task of its own, which runs in the background in a separate conversation with you, and leave a card for it here.", + "Use it for anything bigger than a quick answer: drafting, researching, preparing something, or any job of several steps or more than one reply.", + 'Call it once per piece of work; two separate asks are two tasks. Then reply only "On it.", or "On it" and one short clause, and do not do the work here.', + "Never call it inside a task's own conversation; there, do the work.", + ].join(" "), + parameters: startParameters, + initiator: options.initiator, + async execute(args) { + const parsed = startParameters.safeParse(args ?? {}); + if (!parsed.success) + return `Refused. ${START_TASK_TOOL} takes a title (at most 160 characters) and the instruction (at most ${MAX_INSTRUCTION}); why, source and link are optional, and a link must be an http or https URL. ${parsed.error.issues[0]?.message ?? ""}`.trim(); + // Counted before the start is awaited, so calls made in parallel are held to it too. + if (started >= MAX_TASKS_PER_TURN) + return `Refused. One reply starts at most ${MAX_TASKS_PER_TURN} tasks. Tell the person which ones are under way, and ask them to send the rest in another message.`; + started += 1; + const task = await options.start({ + ownerUserId: options.actorId, + agentId: options.botId, + task: parsed.data, + }); + return JSON.stringify({ + taskId: task.id, + channelId: task.channelId, + title: task.title, + note: 'The task is under way in its own conversation, and its card is under your reply. Reply only "On it.", or "On it" and one short clause. Do not describe the task or what happens next, and do not do the work here.', + }); + }, + }, + ]; +} diff --git a/server/src/tasks/types.ts b/server/src/tasks/types.ts new file mode 100644 index 0000000000..cf993170ec --- /dev/null +++ b/server/src/tasks/types.ts @@ -0,0 +1,87 @@ +/** How a task began: asked for, or started from one of the Bot's suggestions. */ +export type TaskOrigin = "asked" | "suggestion"; + +/** + * How many times a task's turn runs: once, and once more when the first run threw or a restart cut + * it off. The queue's attempt cap for the turn's kind, so a turn out of runs stays on the queue + * unfinished with its count and its reason, which is what reads as `failed` (tasks/store.ts). + */ +export const TASK_RUNS = 2; + +/** + * Where a task stands, worked out on every read, first match wins: + * - `needs_input`: an approval or a question in its conversation waits on the person; + * - `working`: its turn is queued, running, or due its one retry, or an answered approval or question + * is being carried on; + * - `resolved`: the person marked it done; + * - `failed`: its turn ran out of runs without finishing, and nothing has happened in its + * conversation since; + * - `ready_for_review`: its turn finished and wrote a reply. + */ +export type TaskStatus = + | "needs_input" + | "working" + | "resolved" + | "failed" + | "ready_for_review"; + +/** A task as the person reads it. */ +export type TaskRecord = { + id: string; + botId: string; + /** The task's own conversation. */ + channelId: string; + /** + * That conversation's thread, which an approval or a question raised in it names. Null when the + * conversation is gone. + */ + threadId: string | null; + title: string; + why: string | null; + origin: TaskOrigin; + suggestionId: string | null; + sourceApp: string | null; + sourceLink: string | null; + status: TaskStatus; + /** + * Why its turn couldn't finish, in plain words, while it is `failed`, or marked done after failing. + * Null otherwise. + */ + failure: string | null; + /** When it failed, alongside `failure`. */ + failedAt: string | null; + /** + * The run's own next step, one line, for Tasks: the last sentence or question of the Bot's newest + * words in its conversation, or the person's own words in quotes while their reply is being + * worked on. Null before anything has been said there. + */ + next: string | null; + /** The person's replies still waiting their turn, oldest first. */ + queuedReplies: { messageId: string; text: string }[]; + /** Questions the person already answered in accepted replies, even if their turns failed. */ + answeredQuestionIds: string[]; + /** Null once durable work accepted after the person's last completion supersedes it. */ + resolvedAt: string | null; + createdAt: string; + updatedAt: string; +}; + +/** What a task is opened with. Everything else about it is read from the ledgers. */ +export type NewTask = { + ownerUserId: string; + botId: string; + channelId: string; + title: string; + why: string | null; + origin: TaskOrigin; + suggestionId: string | null; + sourceApp: string | null; + sourceLink: string | null; +}; + +export class TaskNotFoundError extends Error { + constructor() { + super("Couldn't find that task."); + this.name = "TaskNotFoundError"; + } +} diff --git a/server/src/work/queue.ts b/server/src/work/queue.ts index 8676a9c127..0c4c435310 100644 --- a/server/src/work/queue.ts +++ b/server/src/work/queue.ts @@ -15,7 +15,18 @@ * Postgres considered expired on arrival, and the next replica to look took the item straight out * from under the first. Both then ran it. Every time this file names a moment it names it in SQL. */ -import { and, eq, gte, isNull, like, lt, or, sql } from "drizzle-orm"; +import { + and, + eq, + gt, + gte, + isNull, + like, + lt, + or, + type SQL, + sql, +} from "drizzle-orm"; import postgres from "postgres"; import type { Database } from "../db/client"; import { workItems } from "../db/schema"; @@ -68,7 +79,8 @@ export type WorkItem = { key: string; payload: Record; /** - * How many times this has been handed out, including now. + * How many times this has been handed out to run, including now. A claim given back with + * `unstarted` does not count. * * ONE MEANS IT HAS CERTAINLY NOT RUN. More than one means a previous owner stopped renewing, and * may already have called a tool or spent money before it did. A caller that cannot tell those @@ -87,6 +99,89 @@ export type WorkItem = { */ export const DEFAULT_MAX_ATTEMPTS = 5; +/** + * Work that has to run one item at a time within a group: a conversation's turns, where a second + * reply must not start while the first is still running, or overtake an older one still waiting. + * + * An item waits while another of its group holds a live lease, or while an older one is unfinished + * and still has attempts left. An older item out of attempts no longer holds the group back, or one + * failure would stop the group for good. + * + * Several kinds can share one order, each with the attempt cap its own worker claims at, which is + * how a sibling of another kind is known to be out of attempts. + */ +export type SerialOrder = { + /** Names the group's lock. The same for every kind that shares the order. */ + name: string; + /** Every kind in the order, the claimed one too, with the attempt cap its worker claims at. */ + kinds: Readonly>; + /** + * The group a row is in, as SQL over the `work_items` row named `row`. Null leaves the row in no + * group, so it runs as it would without an order. + */ + groupOf: (row: SQL) => SQL; +}; + +/** The order a payload field gives: rows of one kind with the same value run one at a time. */ +function orderByField( + kind: string, + field: string, + maxAttempts: number, +): SerialOrder { + return { + name: kind, + kinds: { [kind]: maxAttempts }, + groupOf: (row) => sql`${row}.payload->>${field}`, + }; +} + +/** The row a claim is looking at, and each other row it is weighed against. */ +const due = sql`${sql.identifier("due")}`; +const other = sql`${sql.identifier("other")}`; + +/** + * Whether the row named `due` is held back by another of its group: one that is running, or one + * that is older, unfinished and still has attempts left. Older by when each was made, so an insert + * that commits late keeps its place. + */ +function heldBack(order: SerialOrder) { + const kinds = Object.entries(order.kinds); + const cap = sql`case ${other}.kind ${sql.join( + kinds.map(([name, attempts]) => sql`when ${name} then ${attempts}::int`), + sql` `, + )} end`; + return sql`exists ( + select 1 from "work_items" as ${other} + where ${other}.kind in (${sql.join( + kinds.map(([name]) => sql`${name}`), + sql`, `, + )}) + and (${other}.kind, ${other}.key) != (${due}.kind, ${due}.key) + and ${other}.finished_at is null + and (${other}.attempts < ${cap} or ${other}.lease_until > now()) + and ${order.groupOf(other)} = ${order.groupOf(due)} + and (${other}.lease_until > now() + or (${other}.created_at, ${other}.key, ${other}.kind) < (${due}.created_at, ${due}.key, ${due}.kind)) + )`; +} + +/** + * At most `max` items of one kind running at once for each value `by` gives, as SQL over the + * `work_items` row named `row`: one person's task turns, say. Running is a live lease. + */ +export type RunningCap = { by: (row: SQL) => SQL; max: number }; + +/** Whether the row named `due` would be one too many running for its value of `cap.by`. */ +function atCap(kind: string, cap: RunningCap) { + return sql`( + select count(*) from "work_items" as ${other} + where ${other}.kind = ${kind} + and ${other}.finished_at is null + and ${other}.lease_until > now() + and ${cap.by(other)} = ${cap.by(due)} + ) >= ${cap.max}`; +} + export type WorkQueue = { /** * Put work on the queue, or leave what is there. Idempotent on (kind, key). @@ -123,8 +218,22 @@ export type WorkQueue = { leaseMs: number; limit?: number; maxAttempts?: number; + /** + * Run one item at a time per group ({@link SerialOrder}). A payload field's name is the order + * of this kind alone, grouped by that field's value. + */ + serialBy?: string | SerialOrder; + /** + * Leave an item queued while its value already has as many running as the cap allows + * ({@link RunningCap}). It is claimed once one of those finishes or is let go, never refused. + * One item per claim. + */ + runningAtMost?: RunningCap; }) => Promise; - /** Keep a claim alive while the work runs. False means it was already taken away. */ + /** + * Keep a claim alive while the work runs. False means it expired, finished, or went to somebody + * else. + */ renew: (input: { kind: string; key: string; @@ -132,20 +241,35 @@ export type WorkQueue = { leaseMs: number; }) => Promise; /** - * Done. False means the lease had already gone to somebody else, so this was not ours to finish. + * Done. False means the lease had expired or gone to somebody else, so this was not ours to + * finish. */ finish: (input: { kind: string; key: string; owner: string; + /** + * What the work came to, merged into its payload in the same write, for whatever reads the + * queue afterwards: how a task's turn ended, say. Kept only if the item was still ours. + */ + payload?: Record; }) => Promise; - /** Not done, and worth another go after `delayMs`. False means it was no longer ours. */ + /** + * Not done, and worth another go after `delayMs`. False means the lease had expired or it was no + * longer ours. + */ release: (input: { kind: string; key: string; owner: string; delayMs: number; reason?: string; + /** + * The work never began, so the attempt this claim spent is given back. Only for a refusal + * before anything ran, such as the conversation's lock being held when the turn first asked + * for it: anything later may already have had an effect, and must count. + */ + unstarted?: boolean; }) => Promise; /** * Drop what is done with, older than the retention window. Returns how many went. @@ -191,6 +315,12 @@ export function createWorkQueue(database: Database): WorkQueue { * `finish` and `release` used to match on the key alone, so a replica whose lease had quietly * expired could delete or reschedule an item another replica was in the middle of executing. Only * `renew` got this right; all three ask the same question now. + * + * AND STILL LIVE, on the database's clock as `claim` reads it. The owner alone is not enough: a + * lease that expired on its last attempt is claimed by nobody, so it keeps its owner, while a + * serialised sibling ({@link SerialOrder}) is free to start in its place. A late heartbeat would + * revive it beside that sibling, a late finish or release would write its outcome over the + * sibling's, and a refund would hand it back an attempt it spent. */ const ours = (kind: string, key: string, owner: string) => and( @@ -198,6 +328,7 @@ export function createWorkQueue(database: Database): WorkQueue { eq(workItems.key, key), eq(workItems.claimedBy, owner), isNull(workItems.finishedAt), + gt(workItems.leaseUntil, sql`now()`), ); return { @@ -271,36 +402,92 @@ export function createWorkQueue(database: Database): WorkQueue { leaseMs, limit = 1, maxAttempts = DEFAULT_MAX_ATTEMPTS, + serialBy, + runningAtMost, }) { + const order = + typeof serialBy === "string" + ? orderByField(kind, serialBy, maxAttempts) + : serialBy; + // Its lock below is waited for, and two claims each waiting on the other's would deadlock. + if (runningAtMost && limit !== 1) + throw new Error("A claim under a running cap takes one item."); return database.transaction(async (transaction) => { /* * `skip locked` is what makes this concurrent rather than merely correct. Without it a * second replica blocks on the first replica's rows and the queue serialises; with it, it * walks past them and takes the next free ones. + * + * A row its group holds back is left out here, before the limit, rather than taken and + * dropped below: an older insert that committed late would otherwise fill the only slot on + * every sweep and starve every other group. */ - const due = await transaction.execute(sql` - select "kind", "key" - from "work_items" + const selected = await transaction.execute(sql` + select "kind", "key", ${order ? order.groupOf(due) : sql`null`} as "group", + ${runningAtMost ? runningAtMost.by(due) : sql`null`} as "runner" + from "work_items" as ${due} where "kind" = ${kind} and "finished_at" is null and "attempts" < ${maxAttempts} and "run_at" <= now() and ("lease_until" is null or "lease_until" <= now()) - order by "run_at" asc + ${order ? sql`and not ${heldBack(order)}` : sql``} + ${runningAtMost ? sql`and not ${atCap(kind, runningAtMost)}` : sql``} + order by "run_at" asc, "created_at" asc, "key" asc limit ${limit} for update skip locked `); const rows = ( - Array.isArray(due) ? due : ((due as { rows?: unknown[] })?.rows ?? []) + Array.isArray(selected) + ? selected + : ((selected as { rows?: unknown[] })?.rows ?? []) ) as { kind: string; key: string; + group: string | null; + runner: string | null; }[]; if (rows.length === 0) return []; const claimed: WorkItem[] = []; for (const row of rows) { + if (order && row.group !== null) { + /* + * Asked again, one claimer per group at a time. The select above read one snapshot, and + * a sibling inserted or claimed since is not in it, so two replicas could each find the + * group free and each take a row of it. The lock makes them take turns and the check + * after it reads a fresh snapshot. A group another replica is claiming right now is + * skipped rather than waited for, as `skip locked` skips its rows. + */ + const [lock] = await transaction.execute<{ locked: boolean }>(sql` + select pg_try_advisory_xact_lock(hashtext(${order.name}), hashtext(${row.group})) as "locked" + `); + if (!lock?.locked) continue; + const [check] = await transaction.execute<{ blocked: boolean }>(sql` + select ${heldBack(order)} as "blocked" + from "work_items" as ${due} + where ${due}.kind = ${kind} and ${due}.key = ${row.key} + `); + if (check?.blocked !== false) continue; + } + if (runningAtMost && row.runner !== null) { + /* + * Counted again, one claimer per value at a time: two replicas could each count one + * fewer running than the cap and each start another. Waited for, not skipped as a group + * is: a group runs one item anyway, but under a cap of three a claim that lost the race + * would leave a turn queued with room to run until something else looked. + */ + await transaction.execute(sql` + select pg_advisory_xact_lock(hashtext(${`${kind}:running`}), hashtext(${row.runner})) + `); + const [check] = await transaction.execute<{ full: boolean }>(sql` + select ${atCap(kind, runningAtMost)} as "full" + from "work_items" as ${due} + where ${due}.kind = ${kind} and ${due}.key = ${row.key} + `); + if (check?.full !== false) continue; + } const [updated] = await transaction .update(workItems) .set({ @@ -336,16 +523,16 @@ export function createWorkQueue(database: Database): WorkQueue { .update(workItems) .set({ leaseUntil: fromNow(leaseMs), updatedAt: sql`now()` }) /* - * Only while still ours. A lease that expired and was taken by somebody else must not be - * renewed back out from under them, which would put two replicas on one item believing they - * each held it. + * Only while still ours and still live. A lease that expired and was taken by somebody else + * must not be renewed back out from under them, which would put two replicas on one item + * believing they each held it. */ .where(ours(kind, key, owner)) .returning({ key: workItems.key }); return Boolean(renewed); }, - async finish({ kind, key, owner }) { + async finish({ kind, key, owner, payload }) { const [finished] = await database .update(workItems) /* @@ -358,13 +545,19 @@ export function createWorkQueue(database: Database): WorkQueue { claimedBy: null, leaseUntil: null, updatedAt: sql`now()`, + ...(payload + ? { + // The object itself: the driver encodes it once, and a string would stay one. + payload: sql`${workItems.payload} || ${payload}::jsonb`, + } + : {}), }) .where(ours(kind, key, owner)) .returning({ key: workItems.key }); return Boolean(finished); }, - async release({ kind, key, owner, delayMs, reason }) { + async release({ kind, key, owner, delayMs, reason, unstarted }) { // Freed and pushed out, rather than finished: the work still wants doing, just not immediately // and not by whoever just gave up on it. The reason stays on the row so an item that runs out // of attempts says why rather than simply stopping. @@ -376,6 +569,9 @@ export function createWorkQueue(database: Database): WorkQueue { runAt: fromNow(delayMs), updatedAt: sql`now()`, ...(reason === undefined ? {} : { lastError: reason }), + // Given back once: clearing the owner in the same write means a second release, or a + // stale owner's, matches nothing and refunds nothing. + ...(unstarted ? { attempts: sql`${workItems.attempts} - 1` } : {}), }) .where(ours(kind, key, owner)) .returning({ key: workItems.key }); diff --git a/server/tests/agent-resolver.test.ts b/server/tests/agent-resolver.test.ts index 7e0e5df311..006726106b 100644 --- a/server/tests/agent-resolver.test.ts +++ b/server/tests/agent-resolver.test.ts @@ -58,6 +58,25 @@ describe("actor-scoped agent resolver", () => { ]); }); + test("tells the tools when the turn is a task's own, and says nothing otherwise", async () => { + const turns: unknown[] = []; + const resolver = createActorAgentResolver({ + loadAgents: async () => [ + { id: "risk", name: "Risk", type: "built_in", systemPrompt: "Work." }, + ], + model: { provider: "openai", defaultModel: "unused" }, + resolveModelApiKey: async () => "synthetic-key", + loadToolsForActor: (_id, _cause, turn) => { + turns.push(turn); + return async () => []; + }, + }); + const actor = { id: "u1", role: "user" as const }; + await resolver.resolveAgentForActor(actor, "risk", { task: true }); + await resolver.resolveAgentForActor(actor, "risk"); + expect(turns).toEqual([{ task: true }, undefined]); + }); + test("propagates construction failures instead of reporting an unavailable coworker", async () => { const failure = new Error("key store unavailable"); const resolver = createActorAgentResolver({ diff --git a/server/tests/approvals-question-resume.integration.test.ts b/server/tests/approvals-question-resume.integration.test.ts index 62546dffbb..06c7e91a3c 100644 --- a/server/tests/approvals-question-resume.integration.test.ts +++ b/server/tests/approvals-question-resume.integration.test.ts @@ -44,7 +44,11 @@ afterAll(async () => { .delete(workItems) .where( and( - inArray(workItems.kind, ["person.question", "person.response"]), + inArray(workItems.kind, [ + "person.question", + "person.response", + "proactive.task", + ]), inArray(workItems.key, keys), ), ); @@ -105,6 +109,51 @@ test("the question's initiator survives being asked and answered", async () => { }); }); +/* + * A reply typed into the conversation answers what was waiting there when it was accepted, before + * its turn has run. The inbox must not offer the same question again, or answering it there would + * send a second answer to the same thing. + */ +test("a question a reply already answered is neither listed nor answered again", async () => { + const answered = randomUUID(); + const waiting = randomUUID(); + const reply = randomUUID(); + const theirs = randomUUID(); + keys.push(answered, waiting, reply, theirs); + await database.insert(workItems).values([ + { kind: "person.question", key: answered, payload: question() }, + { kind: "person.question", key: waiting, payload: question() }, + { + kind: "proactive.task", + key: reply, + payload: { ownerUserId: owner, followup: true, questionIds: [answered] }, + }, + // Somebody else's reply naming the waiting question answers nothing of this person's. + { + kind: "proactive.task", + key: theirs, + payload: { + ownerUserId: `${prefix}-other`, + followup: true, + questionIds: [waiting], + }, + }, + ]); + const listed = (await questions.list(owner)).map((entry) => entry.id); + expect(listed).toContain(waiting); + expect(listed).not.toContain(answered); + await expect(questions.respond(owner, answered, "Ramen.")).rejects.toThrow( + "You already answered this question.", + ); + const responses = await database + .select({ key: workItems.key }) + .from(workItems) + .where( + and(eq(workItems.kind, "person.response"), eq(workItems.key, answered)), + ); + expect(responses).toEqual([]); +}); + test("an answer to a responsibility's question resumes it with its own tools, run and initiator", async () => { const recorded: unknown[] = []; const store = { diff --git a/server/tests/channel-activity.integration.test.ts b/server/tests/channel-activity.integration.test.ts index 30e523692f..a3893e44b1 100644 --- a/server/tests/channel-activity.integration.test.ts +++ b/server/tests/channel-activity.integration.test.ts @@ -247,6 +247,7 @@ describe("channel activity", () => { createdAt: expect.any(Date), pinned: false, lastReadAt: null, + taskId: null, }, ]); }); diff --git a/server/tests/channel-routes.test.ts b/server/tests/channel-routes.test.ts index 2109e1ac3a..b644c543e3 100644 --- a/server/tests/channel-routes.test.ts +++ b/server/tests/channel-routes.test.ts @@ -39,6 +39,7 @@ import { channels, deploymentPackages, intelligenceChannelMappings, + tasks, users, } from "../src/db/schema"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; @@ -58,6 +59,7 @@ function channel(overrides: Partial = {}): AgentChannel { threadId: "thread-1", active: true, lastMessageAt: null, + taskId: null, ...overrides, }; } @@ -215,6 +217,7 @@ describe("what a channel is waiting on", () => { createdAt: since, pinned: false, lastReadAt: null, + taskId: null, }); const store = fakeStore({ list: async () => ({ @@ -359,6 +362,7 @@ describe("channel routes", () => { threadId: "thread-1", active: true, lastMessageAt: null, + taskId: null, }, }); expect(fetched.status).toBe(200); @@ -397,6 +401,23 @@ describe("channel routes", () => { }); }); + test("says which task the channel is the conversation of", async () => { + const store = fakeStore({ + async get() { + return channel({ taskId: "task-1" }); + }, + }); + + const fetched = await appFor(store).request( + "http://openbot.test/channel-1", + ); + + expect(fetched.status).toBe(200); + expect(await json(fetched)).toEqual({ + channel: { ...channel(), taskId: "task-1", work: [] }, + }); + }); + test.each([ ["{", "Channel input must be a JSON object."], [JSON.stringify([]), "Channel input must be a JSON object."], @@ -1002,6 +1023,30 @@ describe("channel store integration", () => { expect(await persistentStore.get(actor, created.id)).toEqual(created); }); + test("reads which task a channel is the conversation of", async () => { + const actor = await createPersistentUser(); + const agentId = await createPersistentAgent({ + name: "Task agent", + owner: actor, + }); + const ordinary = await persistentStore.create(actor, [agentId]); + const worked = await persistentStore.create(actor, [agentId]); + createdChannelIds.push(ordinary.id, worked.id); + const taskId = persistentId("task"); + // Removed with its channel, which the cleanup deletes. + await database.insert(tasks).values({ + id: taskId, + ownerUserId: actor.id, + botId: agentId, + channelId: worked.id, + title: "Find a time for coffee", + origin: "asked", + }); + + expect((await persistentStore.get(actor, worked.id))?.taskId).toBe(taskId); + expect((await persistentStore.get(actor, ordinary.id))?.taskId).toBeNull(); + }); + test("does not expose a member's channel to another user through public agents", async () => { const creator = await createPersistentUser(); const otherUser = await createPersistentUser(); @@ -1174,6 +1219,7 @@ describe("channel store integration", () => { threadId: created.threadId, active: true, lastMessageAt: null, + taskId: null, }); const persisted = await persistedChannel(created.id); expect(persisted.channelRow?.name).toBe("Zulu, Alpha"); diff --git a/server/tests/channel-summary.integration.test.ts b/server/tests/channel-summary.integration.test.ts index 367b898bd4..f51e3f49c3 100644 --- a/server/tests/channel-summary.integration.test.ts +++ b/server/tests/channel-summary.integration.test.ts @@ -1,6 +1,7 @@ import { afterAll, afterEach, describe, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; import { FIRING_FRAME, frameFiring } from "@openbot/shared/routine-firing"; +import { frameTaskBrief, TASK_FRAME } from "@openbot/shared/task-brief"; import { eq } from "drizzle-orm"; import { createAgentProfileStore } from "../src/agents/profile-store"; import type { AgentActor } from "../src/agents/profile-types"; @@ -327,6 +328,29 @@ describe("naming a claimed conversation", () => { expect((await summaryOf(channel.id))?.summary).toBe("Standup summary"); }); + test("titles a task's conversation from the person's ask, not the task frame", async () => { + const owner = await createUser(); + const channel = await createUsedChannel(owner); + await offer(channel.id); + + let excerptSeen = ""; + await summariseClaimedChannels( + options({ + // A task's first message is the person's ask inside the frame that says it is a task: see + // `shared/task-brief.ts`. + transcript: transcriptOf(frameTaskBrief("Draft a reply to Rosa.")), + title: async (excerpt) => { + excerptSeen = excerpt; + return "Reply to Rosa"; + }, + }), + ); + + expect(excerptSeen).toContain("Draft a reply to Rosa."); + expect(excerptSeen).not.toContain(TASK_FRAME[0]); + expect((await summaryOf(channel.id))?.summary).toBe("Reply to Rosa"); + }); + test("two replicas racing for the same conversation name it once", async () => { const owner = await createUser(); const channel = await createUsedChannel(owner); diff --git a/server/tests/proactive.test.ts b/server/tests/proactive.test.ts index 76677b69fd..3b2199acc2 100644 --- a/server/tests/proactive.test.ts +++ b/server/tests/proactive.test.ts @@ -6,12 +6,16 @@ import { type RunAgentInput, } from "@ag-ui/client"; import type { GrantedTool } from "@openbot/toolkit/granted-tool"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; import { createTurnRunner, type RunnerLike } from "@openbot/toolkit/run-turn"; import { Observable } from "rxjs"; import { z } from "zod"; import { serverTurnAdmission } from "../src/agents/turn-admission"; import type { ApprovalRecord } from "../src/approvals/types"; -import { createProactiveEngine } from "../src/proactive/engine"; +import { + createProactiveEngine, + type TaskThreads, +} from "../src/proactive/engine"; import { createPrivateShareCheck } from "../src/proactive/private-share"; import { createReadOnlyClassifier, @@ -24,6 +28,16 @@ import type { ProactiveStore } from "../src/proactive/store"; import type { ProactiveSetting } from "../src/proactive/types"; import type { WorkQueue } from "../src/work/queue"; +/** These engines never start a task; one that tried would fail the test loudly. */ +const NO_TASKS: TaskThreads = { + open: async () => { + throw new Error("This test never opens a task."); + }, + scope: async () => null, + failedTurn: async () => { + throw new Error("This test never retries a task."); + }, +}; const catalogue = (effects: Record) => ({ listServers: async () => [ { @@ -326,6 +340,7 @@ describe("proactive run", () => { notify: async (_scope, input) => { notified.push(input.text); }, + tasks: NO_TASKS, startTask: async () => { writes.push("task"); }, @@ -440,6 +455,7 @@ describe("proactive run", () => { threadId: "t", }), notify: async () => undefined, + tasks: NO_TASKS, startTask: async () => undefined, catalogue: { listServers: async () => [ @@ -578,6 +594,7 @@ describe("proactive run", () => { notify: async (_scope, message) => { seen.notified.push(message.text); }, + tasks: NO_TASKS, startTask: async () => undefined, catalogue: { listServers: async () => [] }, mintThreadId: () => "t", @@ -740,3 +757,87 @@ describe("private information sharing check", () => { expect(opened).toHaveLength(0); }); }); + +describe("a started task's turn", () => { + /** An engine with one task's turn queued, whose turn does what `startTask` does. */ + function withTask(startTask: () => Promise) { + const calls: { name: string; args: unknown[] }[] = []; + const record = + (name: string, answer: unknown = undefined) => + async (...args: unknown[]) => { + calls.push({ name, args }); + return answer; + }; + const store = { + claimDue: async () => [], + } as unknown as ProactiveStore; + let queued = [ + { + kind: "proactive.task", + key: "task:t1", + payload: { + ownerUserId: "owner", + taskId: "t1", + text: "Reply to Rosa", + runs: 2, + }, + }, + ]; + const queue = { + offer: record("offer", "queued"), + claim: async ({ kind }: { kind: string }) => { + const items = queued.filter((item) => item.kind === kind); + queued = queued.filter((item) => item.kind !== kind); + return items.map((item) => ({ ...item, attempts: 1 })); + }, + renew: async () => true, + finish: record("finish", true), + release: record("release", true), + purge: async () => 0, + } as unknown as WorkQueue; + const engine = createProactiveEngine({ + store, + memory: { + formMemory: async () => ({ + id: "m", + duplicate: false, + forgotten: false, + }), + }, + queue, + owner: "replica", + runTurn: (async () => ({ replyText: "" })) as never, + resolveScope: async () => null, + notify: async () => undefined, + tasks: { + ...NO_TASKS, + scope: async (ownerUserId) => ({ + ownerUserId, + channelId: "task-channel", + agentId: "bot", + threadId: "task-thread", + }), + }, + startTask, + catalogue: { listServers: async () => [] }, + mintThreadId: () => "t", + taskRetryMs: 0, + }); + return { engine, calls }; + } + const named = (calls: { name: string; args: unknown[] }[], name: string) => + calls.filter((call) => call.name === name).map((call) => call.args); + + test("stopped on an approval, it is finished, never released to be run (and sent) again", async () => { + const { engine, calls } = withTask(async () => { + throw new HeadlessToolSuspension( + "This action is waiting for your approval.", + { kind: "approval", requestId: "approval-1" }, + ); + }); + await engine.sweep(); + await engine.idle(); + expect(named(calls, "finish")).toHaveLength(1); + expect(named(calls, "release")).toEqual([]); + }); +}); diff --git a/server/tests/routine-run-turn.test.ts b/server/tests/routine-run-turn.test.ts index 1628847f1b..3ec95a75f2 100644 --- a/server/tests/routine-run-turn.test.ts +++ b/server/tests/routine-run-turn.test.ts @@ -721,6 +721,39 @@ describe("the lock is released on every exit path", () => { }); describe("cleanup only runs for a lock that was actually taken", () => { + test("only a lock refused on the first ask is safe to defer", async () => { + const conflict = lockConflict(); + const busy = harness({ acquireFails: () => conflict }); + await expect(busy.run()).rejects.toMatchObject({ + name: "ThreadLockBusyError", + cause: conflict, + }); + expect(busy.calls.runs).toEqual([]); + expect(busy.calls.cleaned).toEqual([]); + + // A provider's own 409, or the platform's once the run is under way, is raised as it comes. + const providerConflict = Object.assign(new Error("Provider conflict"), { + status: 409, + }); + await expect( + harness({ acquireFails: () => providerConflict }).run(), + ).rejects.toBe(providerConflict); + await expect( + harness({ drive: ({ observer }) => observer.error(conflict) }).run(), + ).rejects.toBe(conflict); + await expect( + harness({ + renew: () => { + throw conflict; + }, + heartbeatMs: 1, + drive: ({ agent, observer }) => { + agent.onAbort = () => observer.complete(); + }, + }).run(), + ).rejects.toBe(conflict); + }); + test("a rejected acquire never cleans up, renews, or starts the heartbeat", async () => { // `ɵcleanupThreadLock` is DELETE on the platform's lock endpoint. If cleanup ran when the // acquire itself failed, it would delete whoever DOES hold the lock — the person's own browser diff --git a/server/tests/task-report.test.ts b/server/tests/task-report.test.ts new file mode 100644 index 0000000000..a18eb005e6 --- /dev/null +++ b/server/tests/task-report.test.ts @@ -0,0 +1,436 @@ +import { describe, expect, test } from "bun:test"; +import { frameTaskBrief, frameTaskReply } from "@openbot/shared/task-brief"; +import { Hono } from "hono"; +import { + buildTaskReport, + firstSentence, + type ReportApproval, + type ReportInput, + type ReportMessage, + type TaskReportReader, +} from "../src/tasks/report"; +import { createTaskRoutes } from "../src/tasks/routes"; +import type { TaskStore } from "../src/tasks/store"; +import { type TaskRecord, TaskNotFoundError } from "../src/tasks/types"; + +/** + * A task's report, worked out from its conversation: the decisions the person made in it, and the + * Bot's updates and the person's replies in the order they happened. Built from plain values here; + * the route is drawn with a store and a reader in memory. + */ + +/** A connector's answer as the transcript carries it. */ +const connector = (value: unknown) => JSON.stringify(value); + +const ANA = { + id: "4d48cd4aab6ee190", + threadId: "4d48cd4aab6ee190", + from: "Ana Pereira ", + subject: "Could we do 200?", + date: "2026-10-23T01:04:04.000Z", + body: "If you can do 200 mugs instead of 120 for the same Friday date, we'll confirm today.", +}; + +const brief: ReportMessage = { + id: "task-1", + role: "user", + content: frameTaskBrief("Reply to Ana that the order ships Friday."), +}; +const call = (id: string, name: string, args: unknown = {}) => ({ + id, + name, + args: JSON.stringify(args), +}); +const SEND = "mcp__google-workspace__gmail_send_reply"; +const READ = "mcp__google-workspace__gmail_read_message"; + +const send: ReportApproval = { + id: "approval-send", + toolCallId: "call-send", + status: "completed", + decision: "allow_once", + createdAt: new Date("2026-10-23T09:41:10Z"), + decidedAt: new Date("2026-10-23T09:42:00Z"), + completedAt: new Date("2026-10-23T09:42:05Z"), + action: { + toolRef: "openbot/share_private_information", + effect: "share_private", + scope: "external:ana@example.test", + args: { + audience: "google-workspace: ana@example.test", + preview: "Hi Ana, the order ships Friday.", + }, + target: { kind: "external", id: "ana@example.test" }, + }, +}; + +/** The conversation a send-a-reply task leaves: read, ask, allowed, sent, said so. */ +const sendConversation: ReportMessage[] = [ + brief, + { + id: "a1", + role: "assistant", + content: "I read Ana's email. She wants 200 mugs for Friday.", + toolCalls: [call("call-read", READ, { id: ANA.id })], + }, + { id: "t1", role: "tool", toolCallId: "call-read", content: connector(ANA) }, + { id: "reasoning", role: "reasoning", content: "" }, + { + id: "a2", + role: "assistant", + toolCalls: [call("call-send", SEND, { to: ["ana@example.test"] })], + }, + { + id: "approval:approval-send", + role: "tool", + toolCallId: "call-send", + content: "The person allowed this share. Send it again now, unchanged.", + }, + { + id: "a3", + role: "assistant", + toolCalls: [call("call-send-2", SEND, { to: ["ana@example.test"] })], + }, + { + id: "t3", + role: "tool", + toolCallId: "call-send-2", + content: connector({ id: "sent-1", threadId: ANA.threadId }), + }, + { + id: "a4", + role: "assistant", + content: + "**Sent.** Ana now knows the order ships Friday.\n\nAnything else on the Hearth order is with Sam.", + }, +]; + +function input(overrides: Partial = {}): ReportInput { + return { + task: { id: "task-1", status: "ready_for_review" }, + messages: sendConversation, + approvals: [send], + firstTurnEndedAt: new Date("2026-10-23T09:41:12Z"), + resumedEndedAt: new Map([ + ["approval-send", new Date("2026-10-23T09:42:20Z")], + ]), + lastMessageAt: new Date("2026-10-23T09:42:19Z"), + ...overrides, + }; +} + +describe("a task's decisions and updates", () => { + test("in order: what it found, the person's Allow once where the conversation carried on, then what it did", () => { + const report = buildTaskReport(input()); + expect( + report.entries.map((entry) => + entry.kind === "decision" + ? `decision ${entry.decision} by ${entry.by} at ${entry.at}` + : `${entry.kind} ${entry.text} at ${entry.at}`, + ), + ).toEqual([ + // The first turn stopped to ask, so it ended when it asked. + "update I read Ana's email. at 2026-10-23T09:41:10.000Z", + "decision allow_once by you at 2026-10-23T09:42:00.000Z", + // Carried on after the approval: its work item's end. + "update Sent. at 2026-10-23T09:42:20.000Z", + ]); + const decision = report.entries[1]; + expect(decision?.kind === "decision" && decision.action).toEqual({ + toolRef: "openbot/share_private_information", + effect: "share_private", + scope: "external:ana@example.test", + args: send.action.args, + target: { kind: "external", id: "ana@example.test" }, + }); + }); + + test("the task's own ask is not a reply; the person's replies are, in their words", () => { + const report = buildTaskReport( + input({ + messages: [ + ...sendConversation, + { + id: "u2", + role: "user", + content: "Thanks.\nAlso ask Sam which **box** to use.", + }, + { + id: "a5", + role: "assistant", + content: + "I asked Sam in his thread. I'll tell you when he answers.", + }, + { + id: "u3", + role: "user", + content: [{ type: "text", text: "Great" }], + }, + { + id: "a6", + role: "assistant", + content: "Sam says double-wall boxes. Packing starts at noon.", + }, + ], + }), + ); + const lines = report.entries.flatMap((entry) => + entry.kind === "decision" + ? [] + : [`${entry.kind}: ${entry.text} @ ${entry.at}`], + ); + expect(lines).toEqual([ + "update: I read Ana's email. @ 2026-10-23T09:41:10.000Z", + "update: Sent. @ 2026-10-23T09:42:20.000Z", + "reply: Thanks. Also ask Sam which box to use. @ null", + // A turn the person started in their browser left no time anywhere, unless it is the latest. + "update: I asked Sam in his thread. @ null", + "reply: Great @ null", + "update: Sam says double-wall boxes. @ 2026-10-23T09:42:19.000Z", + ]); + }); + + test("successive corrections keep their own words and a replay adds no human reply", () => { + const ask = "Draft a reply to Rosa."; + const firstCorrection = frameTaskReply(ask, "Use the Oat Satin glaze."); + const messages: ReportMessage[] = [ + { id: "initial", role: "user", content: frameTaskBrief(ask) }, + { id: "failed", role: "assistant", content: "I could not finish." }, + { id: "retry", role: "user", content: firstCorrection }, + { id: "failed-again", role: "assistant", content: "Still unavailable." }, + ]; + const report = () => buildTaskReport(input({ messages, approvals: [] })); + expect(report().entries.filter((entry) => entry.kind === "reply")).toEqual([ + { + kind: "reply", + id: "retry", + text: "Use the Oat Satin glaze.", + at: null, + }, + ]); + messages.push( + { + id: "retry-again", + role: "user", + content: frameTaskReply(firstCorrection, "Ask for delivery on Friday."), + }, + { id: "drafted", role: "assistant", content: "Drafted." }, + { id: "replay", role: "user", content: frameTaskBrief(ask) }, + { id: "done", role: "assistant", content: "Checked the draft." }, + ); + expect(report().entries).toEqual([ + { + kind: "update", + id: "failed", + text: "I could not finish.", + at: "2026-10-23T09:41:12.000Z", + }, + { + kind: "reply", + id: "retry", + text: "Use the Oat Satin glaze.", + at: null, + }, + { + kind: "update", + id: "failed-again", + text: "Still unavailable.", + at: null, + }, + { + kind: "reply", + id: "retry-again", + text: "Ask for delivery on Friday.", + at: null, + }, + { kind: "update", id: "drafted", text: "Drafted.", at: null }, + { + kind: "update", + id: "done", + text: "Checked the draft.", + at: "2026-10-23T09:42:19.000Z", + }, + ]); + }); + + test("while the task works, its latest turn is still being written and is left out", () => { + const report = buildTaskReport( + input({ task: { id: "task-1", status: "working" } }), + ); + expect(report.entries.map((entry) => entry.kind)).toEqual([ + "update", + "decision", + ]); + }); + + test("a refusal the conversation has not carried on from sits after the call it was about", () => { + const refused: ReportApproval = { + ...send, + status: "denied", + decision: "deny", + completedAt: null, + }; + const report = buildTaskReport( + input({ + approvals: [refused], + messages: sendConversation.slice(0, 5), + resumedEndedAt: new Map(), + }), + ); + expect( + report.entries.map((entry) => + entry.kind === "decision" ? `decision ${entry.decision}` : entry.kind, + ), + ).toEqual(["update", "decision deny"]); + }); + + test("an approval still waiting is not a decision, and one decided off the record goes last", () => { + const waiting: ReportApproval = { + ...send, + status: "pending", + decision: null, + decidedAt: null, + }; + expect( + buildTaskReport( + input({ approvals: [waiting], messages: sendConversation.slice(0, 5) }), + ).entries.map((entry) => entry.kind), + ).toEqual(["update"]); + const elsewhere: ReportApproval = { + ...send, + id: "approval-other", + toolCallId: "call-not-in-history", + decision: "allow_always", + }; + const report = buildTaskReport(input({ approvals: [send, elsewhere] })); + expect(report.entries.at(-1)).toMatchObject({ + kind: "decision", + id: "approval-other", + decision: "allow_always", + }); + }); + + test("the first turn's update takes its work item's end when it did not stop to ask", () => { + const report = buildTaskReport( + input({ + approvals: [], + messages: [ + brief, + { + id: "a1", + role: "assistant", + content: "Done. The notes are ready.", + }, + { id: "u2", role: "user", content: "Thanks" }, + ], + }), + ); + expect(report.entries[0]).toEqual({ + kind: "update", + id: "a1", + text: "Done.", + at: "2026-10-23T09:41:12.000Z", + }); + }); +}); + +describe("an update's first sentence", () => { + test("stops at a sentence, not at an abbreviation or a number, without markdown, cut at a word", () => { + expect(firstSentence("## Done\n\nThe deck is ready.")).toBe("Done"); + expect(firstSentence("- **Sent** the reply. Ana has it.")).toBe( + "Sent the reply.", + ); + expect( + firstSentence("Order no. 5521 ships e.g. by Friday. Then Monday."), + ).toBe("Order no. 5521 ships e.g. by Friday."); + expect(firstSentence("See [the deck](https://x.test) for more")).toBe( + "See the deck for more", + ); + const long = `I ${"looked through every message about the Hearth order and ".repeat(4)}found it.`; + const cut = firstSentence(long); + expect(cut.length).toBeLessThanOrEqual(161); + expect(cut.endsWith("…")).toBe(true); + }); +}); + +describe("GET /api/tasks/:id/report", () => { + const task: TaskRecord = { + id: "task-1", + botId: "assistant", + channelId: "channel-1", + threadId: "thread-1", + title: "Reply to Ana", + why: null, + origin: "asked", + suggestionId: null, + sourceApp: null, + sourceLink: null, + status: "ready_for_review", + failure: null, + failedAt: null, + next: null, + queuedReplies: [], + answeredQuestionIds: [], + resolvedAt: null, + createdAt: "2026-10-23T09:41:00.000Z", + updatedAt: "2026-10-23T09:41:00.000Z", + }; + const store = { + get: async (owner: string, id: string) => { + if (owner !== "owner" || id !== task.id) throw new TaskNotFoundError(); + return task; + }, + } as unknown as TaskStore; + const routes = (report?: TaskReportReader) => { + const app = new Hono(); + app.route( + "/tasks", + createTaskRoutes( + store, + async (context, next) => { + context.set("actor", { + id: context.req.header("x-actor") ?? "owner", + } as never); + await next(); + }, + { report }, + ), + ); + return app; + }; + + test("answers the owner's task's report, read for that owner", async () => { + const asked: [string, string][] = []; + const response = await routes(async (owner, read) => { + asked.push([owner, read.id]); + return buildTaskReport(input()); + }).request("/tasks/task-1/report"); + expect(response.status).toBe(200); + const body = (await response.json()) as { + report: { taskId: string; entries: unknown[] }; + }; + expect(body.report).toEqual( + JSON.parse(JSON.stringify(buildTaskReport(input()))), + ); + expect(body.report.taskId).toBe("task-1"); + expect(body.report.entries).toHaveLength(3); + expect(asked).toEqual([["owner", "task-1"]]); + }); + + test("another person's task is no task, a failed read says so, and no reader means no route", async () => { + const reader: TaskReportReader = async () => buildTaskReport(input()); + const other = await routes(reader).request("/tasks/task-1/report", { + headers: { "x-actor": "someone-else" }, + }); + expect(other.status).toBe(404); + const failing = await routes(async () => { + throw new Error("Intelligence is down"); + }).request("/tasks/task-1/report"); + expect(failing.status).toBe(503); + expect(await failing.json()).toEqual({ + error: "Couldn't load this task's report. Try again.", + }); + const absent = await routes().request("/tasks/task-1/report"); + expect(absent.status).toBe(404); + }); +}); diff --git a/server/tests/task-threads.integration.test.ts b/server/tests/task-threads.integration.test.ts new file mode 100644 index 0000000000..983b3b6eeb --- /dev/null +++ b/server/tests/task-threads.integration.test.ts @@ -0,0 +1,4261 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + spyOn, + test, +} from "bun:test"; +import { randomUUID } from "node:crypto"; +import { + frameTaskBrief, + frameTaskInput, + frameTaskReply, + readTaskBrief, + readTaskFrame, + TASK_ENDING, + TASK_REPLY_LINE, +} from "@openbot/shared/task-brief"; +import type { TaskReplyContext } from "@openbot/shared/task-reply"; +import { ThreadLockBusyError } from "@openbot/toolkit/lease"; +import type { TurnRunner } from "@openbot/toolkit/run-turn"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { createAgentProfileStore } from "../src/agents/profile-store"; +import type { AgentActor } from "../src/agents/profile-types"; +import { createPersonResponseTick } from "../src/approvals/person-response-sweep"; +import { createApprovalResumeTick } from "../src/approvals/resume-sweep"; +import type { ApprovalService } from "../src/approvals/service"; +import { + createApprovalQuestions, + type PersonQuestion, + questionResponseMessage, +} from "../src/approvals/questions"; +import { type AuditInitiator, createAuditStore } from "../src/audit"; +import type { AuthenticatedActor } from "../src/auth/guards"; +import { + loadAttachmentForTurn, + markAttachmentsSent, +} from "../src/channels/attachments"; +import { createChannelStore } from "../src/channels/routes"; +import { createThreadIdentity } from "../src/channels/thread-identity"; +import { createCredentialStore } from "../src/credentials"; +import { createDatabase } from "../src/db/client"; +import { + agentProfiles, + agents, + approvalRequests, + attachments, + channels, + intelligenceChannelMappings, + pluginGrants, + skills, + tasks, + users, + workItems, +} from "../src/db/schema"; +import { createPluginStore } from "../src/plugins/store"; +import { + createProactiveEngine, + type OpenedTask, + PROACTIVE_RUN_KIND, + PROACTIVE_TASK_KIND, + type ProactiveScope, + type TaskThreads, + TASK_TURNS_PER_PERSON, + type TaskTurnEnding, +} from "../src/proactive/engine"; +import { createProactiveRoutes } from "../src/proactive/routes"; +import { createProactiveStore } from "../src/proactive/store"; +import { + ProactiveNotFoundError, + ProactiveRefusedError, +} from "../src/proactive/types"; +import { createResponsibilityStore } from "../src/responsibilities/store"; +import { createTaskReplyContext } from "../src/tasks/reply-context"; +import { + buildTaskReport, + createTaskReportReader, + type ReportMessage, + textOf, +} from "../src/tasks/report"; +import { createTaskRoutes } from "../src/tasks/routes"; +import { createTaskStore } from "../src/tasks/store"; +import { + createContinuationRunner, + createTaskThreads, + createTaskTurn, +} from "../src/tasks/threads"; +import { + builtInBots, + MAX_TASKS_PER_TURN, + START_TASK_TOOL, + taskTools, +} from "../src/tasks/tools"; +import type { TaskRecord } from "../src/tasks/types"; +import { + createWorkQueue, + type SerialOrder, + type WorkItem, + type WorkQueue, +} from "../src/work/queue"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +/** + * A task is work a Bot does in a conversation of its own, so the conversation it was asked in + * stays one thread. These run + * against PostgreSQL: the conversation is a real channel, and what a task is doing is read from the + * real work queue, approval inbox and question queue. + */ + +// Keep production kinds visible to status readers and first-day transactions, while every SQL +// queue claim uses this file's private table. Other integration files keep their public queue. +const queueSchema = `task_threads_${randomUUID().replaceAll("-", "_")}`; +const databaseUrl = new URL(testDatabaseUrl()); +databaseUrl.searchParams.set("search_path", `${queueSchema},public`); +const database = createDatabase(databaseUrl.toString(), TEST_POOL); +const engines = new Set>(); +const profileStore = createAgentProfileStore( + database, + new URL("https://managed.example.test/ag-ui"), +); +const channelStore = createChannelStore( + database, + profileStore, + createThreadIdentity("test-deployment"), +); +const taskStore = createTaskStore(database); +const proactiveStore = createProactiveStore(database); +const prefix = `task-threads-${randomUUID()}`; +const owner: AgentActor = { id: `${prefix}-owner`, role: "user" }; +const other: AgentActor = { id: `${prefix}-other`, role: "user" }; +let bot = ""; + +const actorFor = async (id: string): Promise => ({ + id, + role: "user", +}); +const scopeFor = async ( + ownerUserId: string, + channelId: string, + agentId: string, +): Promise => { + const channel = await channelStore.get( + await actorFor(ownerUserId), + channelId, + ); + return channel?.agentIds.includes(agentId) + ? { ownerUserId, channelId, agentId, threadId: channel.threadId } + : null; +}; +/** + * What each task's conversation was told about being busy, by thread. Recorded rather than sent: + * an announcement reads the conversation's work list and notifies, and on a two-connection pool a + * burst of them alongside the engine's own reads trips Bun's SQL client, which then decodes one + * statement's rows with another's column types and stops answering. One real announcement is + * exercised on its own, below. + */ +const busySignals: { threadId: string; busy: boolean }[] = []; +const threads = createTaskThreads({ + database, + store: taskStore, + channels: { + create: channelStore.create, + signalBusy: async (threadId, busy) => { + busySignals.push({ threadId, busy }); + }, + }, + actorFor, + scopeFor, +}); + +/** + * The queue in memory, so a task started here is only ever claimed by this file's engine: the shared + * test database can hold other files' queued work, which a real claim would pick up instead. + */ +function memoryQueue() { + const items = new Map< + string, + WorkItem & { finished: boolean; claimed: boolean; lastError?: string } + >(); + const queue = { + offer: async (item: { + kind: string; + key: string; + payload?: Record; + }) => { + const id = `${item.kind}:${item.key}`; + if (items.has(id)) return "already"; + items.set(id, { + kind: item.kind, + key: item.key, + payload: item.payload ?? {}, + attempts: 0, + finished: false, + claimed: false, + }); + return "queued"; + }, + claim: async ({ + kind, + maxAttempts = 5, + }: { + kind: string; + maxAttempts?: number; + }) => { + const due = [...items.values()].find( + (item) => + item.kind === kind && + !item.finished && + !item.claimed && + item.attempts < maxAttempts, + ); + if (!due) return []; + due.claimed = true; + due.attempts += 1; + return [{ ...due }]; + }, + renew: async () => true, + finish: async ({ kind, key }: { kind: string; key: string }) => { + const item = items.get(`${kind}:${key}`); + if (item) item.finished = true; + return true; + }, + release: async ({ + kind, + key, + reason, + }: { + kind: string; + key: string; + reason?: string; + }) => { + const item = items.get(`${kind}:${key}`); + if (item) { + item.claimed = false; + if (reason !== undefined) item.lastError = reason; + } + return true; + }, + purge: async () => 0, + }; + return { queue: queue as unknown as WorkQueue, items }; +} + +type Started = { + scope: ProactiveScope; + runId: string; + text: string; + acceptedReply?: { id: string; text: string }; + final: boolean; +} & TaskReplyContext; + +/** An engine on the memory queue. `turn` is each task turn's outcome; by default it succeeds. */ +function engineFor( + turn: ( + started: Started, + signal: AbortSignal, + ) => Promise | Promise = async () => {}, + durable?: WorkQueue, + replyContext?: ReturnType, + tasks: TaskThreads = threads, + options: { taskTurnsAtOnce?: number } = {}, +) { + const memory = memoryQueue(); + const queue = durable ?? memory.queue; + const items = memory.items; + const started: Started[] = []; + const engine = createProactiveEngine({ + acceptReplyContext: replyContext?.accept, + store: { + ...proactiveStore, + // Task-only sweeps must not admit other tests' shared research schedules. + claimDue: async () => [], + }, + memory: { + formMemory: async () => ({ id: "m", duplicate: false, forgotten: false }), + }, + queue, + owner: "worker", + runTurn: async () => { + throw new Error("No research runs here."); + }, + resolveScope: scopeFor, + notify: async () => undefined, + tasks, + startTask: (scope, input) => { + const run = { + scope, + runId: input.runId, + text: input.text, + ...(input.acceptedReply ? { acceptedReply: input.acceptedReply } : {}), + final: input.final, + ...(input.attachmentIds ? { attachmentIds: input.attachmentIds } : {}), + ...(input.skillIds ? { skillIds: input.skillIds } : {}), + }; + started.push(run); + return turn(run, input.signal); + }, + catalogue: { listServers: async () => [] }, + mintThreadId: () => `${prefix}-research-thread`, + taskRetryMs: 0, + ...options, + }); + engines.add(engine); + return { engine, items, started }; +} + +const chatOf = () => channelStore.direct(owner, bot); + +/** + * A claim on a queue whose kinds carry a test's own prefix, so its rows are only ever this test's. + * The order a task's turns are claimed under names its kinds, so it is renamed to match; its groups + * are read the same way, by the task a payload names. + */ +function prefixedClaim( + input: Parameters[0], + prefix: string, +): Parameters[0] { + const { serialBy } = input; + const order: string | SerialOrder | undefined = + serialBy === undefined || typeof serialBy === "string" + ? serialBy + : { + ...serialBy, + name: prefix + serialBy.name, + kinds: Object.fromEntries( + Object.entries(serialBy.kinds).map(([kind, cap]) => [ + prefix + kind, + cap, + ]), + ), + }; + return { + ...input, + kind: prefix + input.kind, + ...(order === undefined ? {} : { serialBy: order }), + }; +} + +/** One of the person's suggestions from the Bot, as research leaves one, ready to Start. */ +async function suggestionFor(title: string, detail = "") { + const chat = await chatOf(); + const setting = + (await proactiveStore.list(owner.id)).find((row) => row.agentId === bot) ?? + (await proactiveStore.create( + owner.id, + { agentId: bot, channelId: chat.id, enabled: false }, + chat.threadId, + )); + return proactiveStore.addSuggestion({ + ownerUserId: owner.id, + agentId: bot, + settingId: setting.id, + runId: `run-${randomUUID()}`, + title, + detail, + sourceApp: null, + sourceRef: null, + sourceLink: null, + }); +} + +/** A turn for this task on the real queue, in whatever state the test needs. */ +async function turnFor( + taskId: string, + state: Partial = {}, + key = `task:${taskId}`, +) { + await database.insert(workItems).values({ + kind: PROACTIVE_TASK_KIND, + key, + payload: { ownerUserId: owner.id, taskId, text: "Prepare the notes." }, + ...state, + }); + return and(eq(workItems.kind, PROACTIVE_TASK_KIND), eq(workItems.key, key)); +} + +beforeAll(async () => { + await database.execute(sql`create schema ${sql.identifier(queueSchema)}`); + await database.execute(sql` + create table ${sql.identifier(queueSchema)}.work_items + (like public.work_items including all) + `); + await database.insert(users).values([ + { id: owner.id, email: `${owner.id}@example.test` }, + { id: other.id, email: `${other.id}@example.test` }, + ]); + bot = ( + await profileStore.create(owner, { + name: "Assistant", + title: "Works in the background", + roleDescription: "Works for one person.", + visibility: "private", + }) + ).id; +}); + +afterEach(async () => { + // Settle immediate turns and retries before removing even unfinished status-only fixtures. + await Promise.all([...engines].map((engine) => engine.idle())); + engines.clear(); + await database.execute( + sql`truncate ${sql.identifier(queueSchema)}.work_items`, + ); +}); + +afterAll(async () => { + try { + const mine = await database + .select({ id: channels.id }) + .from(channels) + .innerJoin( + intelligenceChannelMappings, + eq(intelligenceChannelMappings.channelId, channels.id), + ) + .where(inArray(intelligenceChannelMappings.userId, [owner.id, other.id])); + if (mine.length > 0) + await database.delete(channels).where( + inArray( + channels.id, + mine.map((row) => row.id), + ), + ); + await database.delete(users).where(inArray(users.id, [owner.id, other.id])); + await database.delete(agentProfiles).where(eq(agentProfiles.agentId, bot)); + await database.delete(agents).where(eq(agents.id, bot)); + } finally { + try { + await database.execute( + sql`drop schema if exists ${sql.identifier(queueSchema)} cascade`, + ); + } finally { + await database.$client.close(); + } + } +}); + +describe("a task asked for", () => { + test("has no cap: every call opens another task in another conversation", async () => { + const { engine, started } = engineFor(); + const opened = await Promise.all( + Array.from({ length: 12 }, (_, index) => + engine.startAsked(owner.id, bot, { + title: `Task ${index}`, + instruction: `Do thing ${index}`, + }), + ), + ); + await engine.idle(); + expect(new Set(opened.map((task) => task.channelId)).size).toBe(12); + expect(started).toHaveLength(12); + }); + + test("Chat's conversation with the Bot stays Chat's, however recently a task ran", async () => { + const chat = await chatOf(); + const { engine } = engineFor(); + const task = await engine.startAsked(owner.id, bot, { + title: "Prepare notes for the Hearth & Co visit", + instruction: "Prepare notes for the Hearth & Co visit.", + }); + await engine.idle(); + await channelStore.recordActivity(owner, task.channelId, { + text: "Here are the notes.", + agentId: bot, + at: new Date(Date.now() + 60_000), + }); + expect((await chatOf()).id).toBe(chat.id); + + // The roster still lists it, marked, so the app can keep it out of Chat and the menu. + const page = await channelStore.list(owner, { limit: 200 }); + expect(page.channels.find((row) => row.id === task.channelId)?.taskId).toBe( + task.id, + ); + expect(page.channels.find((row) => row.id === chat.id)?.taskId).toBeNull(); + // Research's defaults never adopt a task's conversation as the person's own. + expect(await taskStore.isTaskConversation(owner.id, task.channelId)).toBe( + true, + ); + expect(await taskStore.isTaskConversation(owner.id, chat.id)).toBe(false); + expect(await taskStore.isTaskConversation(other.id, task.channelId)).toBe( + false, + ); + }); +}); + +describe("Start on a suggestion", () => { + test("opens a task in a conversation of its own instead of running in Chat", async () => { + const chat = await chatOf(); + const setting = + (await proactiveStore.list(owner.id)).find( + (row) => row.agentId === bot, + ) ?? + (await proactiveStore.create( + owner.id, + { agentId: bot, channelId: chat.id, enabled: false }, + chat.threadId, + )); + const add = (title: string) => + proactiveStore.addSuggestion({ + ownerUserId: owner.id, + agentId: bot, + settingId: setting.id, + runId: `run-${randomUUID()}`, + title, + detail: "Ravi publishes in 7 days.\n\nNext: Reply to Ravi.", + sourceApp: "Gmail", + sourceRef: null, + sourceLink: "https://mail.google.com/mail/u/0/#inbox/n26", + }); + const fromTasks = await add("Triage the order-form report"); + const another = await add("Answer Sam about Friday off"); + const { engine, started } = engineFor(); + + const { suggestion, task } = await engine.start(owner.id, fromTasks.id); + await engine.idle(); + expect(suggestion.status).toBe("started"); + const record = await taskStore.get(owner.id, task.id); + expect(record).toMatchObject({ + origin: "suggestion", + suggestionId: fromTasks.id, + title: "Triage the order-form report", + why: "Ravi publishes in 7 days.", + sourceApp: "Gmail", + sourceLink: "https://mail.google.com/mail/u/0/#inbox/n26", + }); + expect(record.channelId).not.toBe(chat.id); + expect(started).toHaveLength(1); + expect(started[0]?.scope.channelId).toBe(record.channelId); + expect(readTaskBrief(started[0]?.text ?? "")).toContain( + "Triage the order-form report", + ); + + // Started once: a second Start is refused and opens nothing. + await expect(engine.start(owner.id, fromTasks.id)).rejects.toThrow( + "already started", + ); + // Somebody else's suggestion is no suggestion at all. + await expect(engine.start(other.id, another.id)).rejects.toBeInstanceOf( + ProactiveNotFoundError, + ); + expect((await proactiveStore.suggestion(owner.id, another.id)).status).toBe( + "open", + ); + + // Nobody else's Start touched it: it still opens a task of its own. + const second = await engine.start(owner.id, another.id); + await engine.idle(); + expect(second.task.channelId).not.toBe(record.channelId); + expect(started).toHaveLength(2); + }); +}); + +describe("Start, through the routes", () => { + test("answers with the suggestion and the task it opened, and reads a suggestion whatever its status", async () => { + const { engine, started } = engineFor(); + const routes = createProactiveRoutes( + proactiveStore, + engine, + async (context, next) => { + context.set("actor", { + id: context.req.header("x-person") ?? owner.id, + } as AuthenticatedActor); + await next(); + }, + ); + const suggestion = await suggestionFor( + "Reply to Ravi about the deadline", + "Ravi publishes in 7 days.\n\nNext: Reply to Ravi.", + ); + const read = (person = owner.id) => + routes.request(`/suggestions/${suggestion.id}`, { + headers: { "x-person": person }, + }); + expect( + ((await (await read()).json()) as { suggestion: { status: string } }) + .suggestion.status, + ).toBe("open"); + + const response = await routes.request( + `/suggestions/${suggestion.id}/start`, + { + method: "POST", + }, + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + suggestion: { id: string; status: string }; + task: { id: string; channelId: string; title: string }; + }; + await engine.idle(); + expect(body.suggestion).toMatchObject({ + id: suggestion.id, + status: "started", + }); + expect(body.task.title).toBe("Reply to Ravi about the deadline"); + expect((await taskStore.get(owner.id, body.task.id)).suggestionId).toBe( + suggestion.id, + ); + expect(started.map((run) => run.scope.channelId)).toEqual([ + body.task.channelId, + ]); + // Started, it is still the person's to read, and still nobody else's. + expect( + ((await (await read()).json()) as { suggestion: { status: string } }) + .suggestion.status, + ).toBe("started"); + expect((await read(other.id)).status).toBe(404); + }); +}); + +describe("while a task's turn runs", () => { + test("its conversation is working on it, for as long as the turn holds its lease, and only for its person", async () => { + const task = await threads.open({ + ownerUserId: owner.id, + agentId: bot, + title: `Working ${randomUUID()}`, + why: null, + origin: "asked", + suggestionId: null, + sourceApp: null, + sourceLink: null, + }); + const conversation = await channelStore.get(owner, task.channelId); + const thread = { + id: task.channelId, + threadId: conversation?.threadId ?? "", + }; + const workOf = async (actor = owner) => + (await channelStore.workIn(actor, [thread])).get(task.channelId) ?? []; + // Queued, nothing runs in the conversation yet. + const where = await turnFor(task.id); + expect(await workOf()).toEqual([]); + await database + .update(workItems) + .set({ + attempts: 1, + claimedBy: "worker", + leaseUntil: sql`now() + interval '1 minute'`, + }) + .where(where); + expect(await workOf()).toEqual([ + { + kind: "task", + key: `task:${task.id}`, + taskId: task.id, + botId: bot, + botName: "Assistant", + since: expect.any(Date), + }, + ]); + // Another person reading the same conversation's id hears nothing of it. + expect(await workOf(other)).toEqual([]); + // Its lease run out, or its turn finished: nothing is running. + await database + .update(workItems) + .set({ leaseUntil: sql`now() - interval '1 second'` }) + .where(where); + expect(await workOf()).toEqual([]); + }); + + test("its conversation is told it is no longer busy once the turn is off the queue, and its work list says so", async () => { + // What the queue held for the turn at the moment the conversation was told. + const finishedWhenTold: (boolean | undefined)[] = []; + let items: ReturnType["items"] | undefined; + let key = ""; + const watching = createTaskThreads({ + database, + store: taskStore, + channels: { + create: channelStore.create, + signalBusy: async (_threadId, busy) => { + if (!busy) finishedWhenTold.push(items?.get(key)?.finished); + }, + }, + actorFor, + scopeFor, + }); + const harness = engineFor(async () => {}, undefined, undefined, watching); + items = harness.items; + const task = await harness.engine.startAsked(owner.id, bot, { + title: "Prepare the notes", + instruction: "Prepare the notes.", + }); + key = `${PROACTIVE_TASK_KIND}:task:${task.id}`; + await harness.engine.idle(); + // Told once, after the turn was finished on the queue, never before. + expect(finishedWhenTold).toEqual([true]); + + // The real announcement, on its own: the turn is finished, so the list it carries is empty. + const real = createTaskThreads({ + database, + store: taskStore, + channels: channelStore, + actorFor, + scopeFor, + }); + await real.settled?.(owner.id, task.id); + const conversation = await channelStore.get(owner, task.channelId); + const thread = { + id: task.channelId, + threadId: conversation?.threadId ?? "", + }; + const work = await channelStore.workIn(owner, [thread]); + expect(work.has(task.channelId)).toBe(false); + }); +}); + +describe("status", () => { + async function freshTask() { + const opened = await threads.open({ + ownerUserId: owner.id, + agentId: bot, + title: `Status ${randomUUID()}`, + why: null, + origin: "asked", + suggestionId: null, + sourceApp: null, + sourceLink: null, + }); + const conversation = await channelStore.get(owner, opened.channelId); + return { id: opened.id, threadId: conversation?.threadId ?? "" }; + } + const statusOf = async (id: string) => + (await taskStore.get(owner.id, id)).status; + + async function acceptedAnswer() { + const task = await freshTask(); + // Well before the reply: a host clock a few milliseconds ahead of the database's would + // otherwise put this turn's end after the reply's failure. + await turnFor(task.id, { finishedAt: new Date(Date.now() - 60_000) }); + const questionId = `${prefix}-answered-${randomUUID()}`; + const question = { + ownerUserId: owner.id, + actorId: owner.id, + botId: bot, + threadId: task.threadId, + runId: `task-${task.id}`, + question: "Which deck?", + mode: "completed_question", + }; + await database.insert(workItems).values({ + kind: "person.question", + key: questionId, + payload: question, + }); + // Persist through the real acceptance path, then advance only this turn's ledger below. + const queue: WorkQueue = { + ...createWorkQueue(database), + claim: async () => [], + }; + const { engine } = engineFor(async () => {}, queue); + const messageId = randomUUID(); + await engine.reply(owner.id, task.id, { reply: "Use A", messageId }); + await engine.idle(); + const where = and( + eq(workItems.kind, PROACTIVE_TASK_KIND), + eq(workItems.key, `task:${task.id}:reply:${messageId}`), + ); + return { task, questionId, question, where, engine }; + } + + test("question-card responses stay working, fail once, and retry the accepted answer", async () => { + const task = await freshTask(); + const unrelated = await freshTask(); + await turnFor(task.id, { finishedAt: new Date() }); + await turnFor(unrelated.id, { finishedAt: new Date() }); + const { channelId } = (await taskStore.locate(owner.id, task.id))!; + const key = `${prefix}-question-response-${randomUUID()}`; + const question: PersonQuestion = { + actorId: owner.id, + botId: bot, + channelId, + threadId: task.threadId, + runId: `task-${task.id}`, + question: + "Which day works after preparing the notes, reviewing the deck, and confirming every attendee can join? ".repeat( + 3, + ), + mode: "completed_question", + }; + const response = "Friday"; + const message = questionResponseMessage( + question, + response, + `person-response:${key}`, + ); + const queue = createWorkQueue(database); + await queue.offer({ kind: "person.question", key, payload: question }); + await channelStore.recordActivity(owner, channelId, { + text: question.question, + agentId: bot, + at: new Date(), + }); + await createApprovalQuestions(database, async () => {}).respond( + owner.id, + key, + response, + ); + expect(await statusOf(task.id)).toBe("working"); + expect(await statusOf(unrelated.id)).toBe("ready_for_review"); + await queue.claim({ + kind: "person.response", + owner: "answerer", + leaseMs: 60_000, + maxAttempts: 1, + }); + expect(await statusOf(task.id)).toBe("working"); + await queue.release({ + kind: "person.response", + key, + owner: "answerer", + delayMs: 0, + reason: "fetch failed", + }); + expect(await taskStore.get(owner.id, task.id)).toMatchObject({ + status: "failed", + failure: "a service didn't answer", + }); + const messages: ReportMessage[] = [ + message, + { + id: "answer-failed", + role: "assistant", + content: "The service failed.", + }, + ]; + const report = createTaskReportReader({ + database, + history: async () => messages, + }); + const entries = async () => + ( + await report(owner.id, await taskStore.get(owner.id, task.id)) + ).entries.map(({ at, ...entry }) => entry); + const original = [ + { kind: "reply", id: message.id, text: "Friday" }, + { kind: "update", id: "answer-failed", text: "The service failed." }, + ] satisfies Awaited>; + expect(await entries()).toEqual(original); + const taskTurn = createTaskTurn({ + actorFor, + channels: channelStore, + runTurn: async (input) => { + if (input.userMessage?.role !== "user") + throw new Error("Task user message is missing"); + messages.push(input.userMessage); + messages.push({ + id: "answer-retried", + role: "assistant", + content: "The deck is ready.", + }); + return { replyText: "The deck is ready." }; + }, + }); + const { engine, started } = engineFor( + async ({ scope, ...input }, signal) => { + await taskTurn(scope, { ...input, signal }); + }, + queue, + ); + await engine.again(owner.id, task.id); + await engine.idle(); + expect({ started, status: await statusOf(task.id) }).toMatchObject({ + started: [ + { + text: message.content, + acceptedReply: { id: message.id, text: response }, + }, + ], + status: "ready_for_review", + }); + expect(await entries()).toEqual([ + ...original, + { kind: "update", id: "answer-retried", text: "The deck is ready." }, + ]); + }); + + test("question-card answers execute before later task composer replies", async () => { + const task = await freshTask(); + await turnFor(task.id, { finishedAt: new Date() }); + const queue = createWorkQueue(database); + const key = randomUUID(); + const question: PersonQuestion = { + actorId: owner.id, + botId: bot, + threadId: task.threadId, + runId: `task-${task.id}`, + question: "Deck?", + mode: "completed_question", + }; + await queue.offer({ kind: "person.question", key, payload: question }); + const questions = createApprovalQuestions(database, async () => {}); + await questions.respond(owner.id, key, "Pottery."); + const starts: string[] = []; + const { engine } = engineFor(async ({ text }) => { + starts.push(text); + }, queue); + const reply = { reply: "Use revised.", messageId: randomUUID() }; + const replyKey = `task:${task.id}:reply:${reply.messageId}`; + const answer = "In response to your question: Deck?\n\nPottery."; + await engine.reply(owner.id, task.id, reply); + const rows = sql` + select kind, key, attempts, finished_at is not null as finished, + coalesce(lease_until > now(), false) as live, last_error as error + from work_items where kind in ('person.response', 'proactive.task') + and key in (${key}, ${replyKey}) order by created_at, key + `; + expect([...(await database.execute(rows))]).toMatchObject([ + { kind: "person.response", key, attempts: 0, finished: false }, + { kind: PROACTIVE_TASK_KIND, key: replyKey }, + ]); + await engine.idle(); + await engine.sweep(); + expect(starts).toEqual([]); + const entered = Promise.withResolvers(); + const held = Promise.withResolvers(); + const answering = createPersonResponseTick({ + approvalQueue: queue, + personResponseOwner: "answerer", + authoriseQuestion: async () => {}, + approvalContinuationRunner: async (input) => { + starts.push(textOf(input.userMessage?.content)); + entered.resolve(); + await held.promise; + return { replyText: "" }; + }, + responsibilityStore: createResponsibilityStore(database, { + resolveTarget: async () => null, + }), + responsibilityAnswerTurn: () => { + throw new Error("Unexpected responsibility"); + }, + sourceForCoordinationRun: async () => null, + channelStore, + actorFor, + notify: async () => {}, + })(); + try { + await Promise.race([entered.promise, answering]); + await engine.sweep(); + const running = [...(await database.execute(rows))]; + expect({ starts, work: running }).toMatchObject({ + starts: [answer], + work: [ + { attempts: 1, live: true, finished: false, error: null }, + { attempts: 0 }, + ], + }); + } finally { + held.resolve(); + await answering; + } + await engine.sweep(); + const finished = [...(await database.execute(rows))]; + expect({ starts, work: finished }).toMatchObject({ + starts: [answer, reply.reply], + work: [ + { attempts: 1, finished: true, error: null }, + { attempts: 1, finished: true, error: null }, + ], + }); + }); + + test("Allow once carries its turn on in the task's order: after a reply sent before it, before one sent after", async () => { + const task = await freshTask(); + const queue = createWorkQueue(database); + const approvalId = `${prefix}-allow-${randomUUID()}`; + await database.insert(approvalRequests).values({ + id: approvalId, + ownerUserId: owner.id, + runId: `task-${task.id}`, + toolCallId: "send", + actionDigest: "digest", + status: "approved", + decision: "allow_once", + action: { + threadId: task.threadId, + botId: bot, + toolRef: "google-workspace/gmail_send_reply", + } as never, + }); + // What ran, in order. A step with a gate holds there until the test lets it go. + const order: string[] = []; + const gates = new Map< + string, + { + entered: PromiseWithResolvers; + release: PromiseWithResolvers; + } + >(); + const gate = (step: string) => { + const held = { + entered: Promise.withResolvers(), + release: Promise.withResolvers(), + }; + gates.set(step, held); + return held; + }; + const run = async (step: string) => { + order.push(step); + gates.get(step)?.entered.resolve(); + await gates.get(step)?.release.promise; + }; + const { engine } = engineFor( + ({ acceptedReply }) => run(acceptedReply?.text ?? ""), + queue, + ); + const resume = createApprovalResumeTick({ + approvalQueue: queue, + approvalOwner: "approver", + approvalService: { + resume: () => run("allowed"), + } as unknown as ApprovalService, + executeApprovedAction: async () => ({ content: "" }), + database, + responsibilityStore: createResponsibilityStore(database, { + resolveTarget: async () => null, + }), + groupConversations: { continueWaiting: async (_id, turn) => turn() }, + approvalContinuationRunner: async () => ({ replyText: "" }), + channelStore, + actorFor, + notify: async () => {}, + }); + + // "Make it shorter" is running when the person allows the step: the resume waits for it. + const shorter = gate("Make it shorter"); + await engine.reply(owner.id, task.id, { + reply: "Make it shorter", + messageId: randomUUID(), + }); + await shorter.entered.promise; + await queue.offer({ + kind: "approval.resume", + key: approvalId, + payload: { ownerUserId: owner.id, approvalId }, + }); + await resume(); + expect(order).toEqual(["Make it shorter"]); + shorter.release.resolve(); + await engine.idle(); + + // The resume is running when the person sends "Send it": the reply waits for it. + const allowed = gate("allowed"); + const resuming = resume(); + await allowed.entered.promise; + await engine.reply(owner.id, task.id, { + reply: "Send it", + messageId: randomUUID(), + }); + await engine.idle(); + expect(order).toEqual(["Make it shorter", "allowed"]); + allowed.release.resolve(); + await resuming; + await engine.sweep(); + await engine.idle(); + expect(order).toEqual(["Make it shorter", "allowed", "Send it"]); + }); + + test("an exhausted accepted answer exposes its failure and remains recoverable through Try again", async () => { + const { task, questionId, where, engine } = await acceptedAnswer(); + expect(await statusOf(task.id)).toBe("working"); + await database.update(workItems).set({ attempts: 1 }).where(where); + expect(await statusOf(task.id)).toBe("working"); + await database + .update(workItems) + .set({ + attempts: 2, + claimedBy: "worker", + leaseUntil: sql`now() + interval '1 minute'`, + }) + .where(where); + expect(await statusOf(task.id)).toBe("working"); + const failedAt = new Date(); + await database + .update(workItems) + .set({ + attempts: 2, + claimedBy: null, + leaseUntil: null, + lastError: "fetch failed", + updatedAt: failedAt, + }) + .where(where); + const routes = createTaskRoutes( + taskStore, + async (context, next) => { + context.set("actor", { + id: owner.id, + email: `${owner.id}@example.test`, + role: "user", + }); + await next(); + }, + { retry: engine }, + ); + const failed = (await (await routes.request(`/${task.id}`)).json()) as { + task: TaskRecord; + }; + expect(failed.task).toMatchObject({ + status: "failed", + failure: "a service didn't answer", + failedAt: failedAt.toISOString(), + answeredQuestionIds: [questionId], + }); + const listed = (await (await routes.request("/")).json()) as { + tasks: TaskRecord[]; + }; + expect(listed.tasks.find((row) => row.id === task.id)).toEqual(failed.task); + // The failed run did not finish the question; the accepted answer is what the read knows. + expect( + await database + .select({ finishedAt: workItems.finishedAt }) + .from(workItems) + .where( + and( + eq(workItems.kind, "person.question"), + eq(workItems.key, questionId), + ), + ), + ).toEqual([{ finishedAt: null }]); + const response = await routes.request(`/${task.id}/again`, { + method: "POST", + }); + await engine.idle(); + expect(response.status).toBe(200); + expect( + ((await response.json()) as { task: TaskRecord }).task, + ).toMatchObject({ + status: "working", + failure: null, + failedAt: null, + answeredQuestionIds: [questionId], + }); + await database + .update(workItems) + .set({ attempts: 1, finishedAt: new Date() }) + .where( + and( + eq(workItems.kind, PROACTIVE_TASK_KIND), + eq(workItems.key, `task:${task.id}:again:2`), + ), + ); + expect((await taskStore.get(owner.id, task.id)).status).toBe( + "ready_for_review", + ); + }); + + test("a new question keeps priority over an accepted answer while working and after exhaustion", async () => { + const { task, question, where } = await acceptedAnswer(); + const newId = `${prefix}-new-${randomUUID()}`; + await database.insert(workItems).values({ + kind: "person.question", + key: newId, + payload: question, + }); + expect(await statusOf(task.id)).toBe("needs_input"); + await database + .update(workItems) + .set({ attempts: 2, lastError: "fetch failed" }) + .where(where); + expect(await taskStore.get(owner.id, task.id)).toMatchObject({ + status: "needs_input", + failure: null, + failedAt: null, + }); + await taskStore.answerQuestions(owner.id, [newId]); + expect(await statusOf(task.id)).toBe("failed"); + }); + + test("pending approval keeps priority over an accepted answer while working and after exhaustion", async () => { + const { task, where } = await acceptedAnswer(); + const approvalId = `${prefix}-answer-approval-${randomUUID()}`; + await database.insert(approvalRequests).values({ + id: approvalId, + ownerUserId: owner.id, + runId: `task-${task.id}`, + toolCallId: "send", + actionDigest: "digest", + action: { + actorId: owner.id, + botId: bot, + threadId: task.threadId, + runId: `task-${task.id}`, + toolCallId: "send", + actionDigest: "digest", + toolRef: "gmail/send", + effect: "write", + scope: "recipient@example.test", + args: {}, + }, + }); + expect(await statusOf(task.id)).toBe("needs_input"); + await database + .update(workItems) + .set({ attempts: 2, lastError: "fetch failed" }) + .where(where); + expect(await statusOf(task.id)).toBe("needs_input"); + await database + .update(approvalRequests) + .set({ status: "completed" }) + .where(eq(approvalRequests.id, approvalId)); + expect(await statusOf(task.id)).toBe("failed"); + }); + + test("a legacy answer without a snapshot never hides an unknown pending question", async () => { + const { task, where } = await acceptedAnswer(); + await database + .update(workItems) + .set({ payload: sql`${workItems.payload} - 'questionIds'` }) + .where(where); + expect(await statusOf(task.id)).toBe("needs_input"); + await database + .update(workItems) + .set({ attempts: 2, lastError: "fetch failed" }) + .where(where); + expect(await statusOf(task.id)).toBe("needs_input"); + }); + + test("carries the run's own next step: the Bot's last sentence, the person's reply quoted, never the ask", async () => { + const opened = await threads.open({ + ownerUserId: owner.id, + agentId: bot, + title: `Next ${randomUUID()}`, + why: null, + origin: "asked", + suggestionId: null, + sourceApp: null, + sourceLink: null, + }); + const nextOf = async () => (await taskStore.get(owner.id, opened.id)).next; + expect(await nextOf()).toBeNull(); + const at = Date.now(); + // The ask the turn starts on is the task, not a step. + await channelStore.recordActivity( + owner, + opened.channelId, + { text: "Draft a reply to Danny.", agentId: null, at: new Date(at) }, + { id: `task-${opened.id}:input` }, + ); + expect(await nextOf()).toBeNull(); + await channelStore.recordActivity(owner, opened.channelId, { + text: "I drafted the reply. **Send the reply to Danny?** It tells him Friday works.", + agentId: bot, + at: new Date(at + 1_000), + }); + expect(await nextOf()).toBe("Send the reply to Danny?"); + await channelStore.recordActivity(owner, opened.channelId, { + text: "Make it shorter", + agentId: null, + at: new Date(at + 2_000), + }); + expect(await nextOf()).toBe("“Make it shorter”"); + // Read the same way on the list. + expect( + (await taskStore.list(owner.id)).find((task) => task.id === opened.id) + ?.next, + ).toBe("“Make it shorter”"); + }); + + test("a finished reply asks for input, and queued replies clear that question while working", async () => { + const task = await freshTask(); + const where = await turnFor(task.id); + await database + .update(workItems) + .set({ finishedAt: new Date() }) + .where(where); + const located = await taskStore.locate(owner.id, task.id); + await channelStore.recordActivity(owner, located!.channelId, { + text: "Here is the draft.\n\nWhich one should I use? 🙂", + agentId: bot, + at: new Date(), + }); + expect(await statusOf(task.id)).toBe("needs_input"); + expect((await taskStore.get(owner.id, task.id)).next).toContain( + "Which one", + ); + await database.insert(workItems).values({ + kind: PROACTIVE_TASK_KIND, + key: `reply-${task.id}`, + payload: { + taskId: task.id, + ownerUserId: owner.id, + reply: "Use A", + messageId: "reply-a", + }, + }); + expect(await statusOf(task.id)).toBe("working"); + }); + + test("a closing question too long for its conversation's line still asks for input", async () => { + const question = + "Should I send Danny the reply now, with the Friday time moved to ten, the studio on Hearth Street as the place, and the note about parking in the yard behind the building, or would you like to read it through first?"; + // Longer than the line a conversation keeps, which cuts it before its question mark. + expect(Array.from(question).length).toBeGreaterThan(200); + const turn = createTaskTurn({ + actorFor, + channels: { + recordActivity: channelStore.recordActivity, + signalBusy: async () => {}, + }, + runTurn: async () => ({ + replyText: `I drafted the reply to Danny.\n\n${question}`, + }), + }); + const { engine } = engineFor( + ({ scope, ...input }, signal) => turn(scope, { ...input, signal }), + createWorkQueue(database), + ); + const task = await engine.startAsked(owner.id, bot, { + title: "Reply to Danny", + instruction: "Draft a reply to Danny.", + }); + await engine.idle(); + expect(await taskStore.get(owner.id, task.id)).toMatchObject({ + status: "needs_input", + next: expect.stringMatching(/^Should I send Danny/), + }); + }); + + async function resolvedReply() { + const task = await freshTask(); + const queue: WorkQueue = { + ...createWorkQueue(database), + claim: async () => [], + }; + const { engine } = engineFor(async () => {}, queue); + const input = { reply: "Prepare the first draft", messageId: randomUUID() }; + await engine.reply(owner.id, task.id, input); + await engine.idle(); + const taskTurns = and( + eq(workItems.kind, PROACTIVE_TASK_KIND), + sql`${workItems.payload}->>'taskId' = ${task.id}`, + ); + await database + .update(workItems) + .set({ attempts: 1, finishedAt: sql`now()` }) + .where(taskTurns); + const located = await taskStore.locate(owner.id, task.id); + await channelStore.recordActivity(owner, located!.channelId, { + text: "Should I send the old draft?", + agentId: bot, + at: new Date(), + }); + const resolved = await taskStore.resolve(owner.id, task.id); + expect(resolved.status).toBe("resolved"); + const accepted = () => database.select().from(workItems).where(taskTurns); + return { + task, + queue, + engine, + input, + resolved, + accepted, + taskTurns, + channelId: located!.channelId, + }; + } + + test("new durable work after resolution exposes its final question", async () => { + const text = "Should I send the revised draft?"; + const { task, engine, input, resolved, accepted, taskTurns, channelId } = + await resolvedReply(); + const messages: ReportMessage[] = [ + { id: input.messageId, role: "user", content: input.reply }, + { + id: "completed-answer", + role: "assistant", + content: "Should I send the old draft?", + }, + ]; + const report = createTaskReportReader({ + database, + history: async () => messages, + }); + const before = await report(owner.id, resolved); + expect(before.entries.filter((entry) => entry.kind === "update")).toEqual([ + { + kind: "update", + id: "completed-answer", + text: "Should I send the old draft?", + at: expect.any(String), + }, + ]); + await engine.reply(owner.id, task.id, { + reply: "Revise it", + messageId: randomUUID(), + }); + await engine.idle(); + expect(await accepted()).toHaveLength(2); + const queued = await taskStore.get(owner.id, task.id); + expect( + (await accepted()).find((row) => row.finishedAt === null), + ).toMatchObject({ + attempts: 0, + }); + // A reply still waiting adds nothing to the report: its turn has said nothing yet. + expect(await report(owner.id, queued)).toEqual(before); + await database + .update(workItems) + .set({ finishedAt: sql`now()` }) + .where(taskTurns); + // Accepted work supersedes completion, but cannot resurrect the old dismissed question. + const withoutNewOutput = await taskStore.get(owner.id, task.id); + await channelStore.recordActivity(owner, channelId, { + text, + agentId: bot, + at: new Date(), + }); + const expected = { status: "needs_input", resolvedAt: null, next: text }; + expect(await taskStore.get(owner.id, task.id)).toMatchObject(expected); + expect(queued).toMatchObject({ status: "working", resolvedAt: null }); + expect(withoutNewOutput).toMatchObject({ + status: "ready_for_review", + resolvedAt: null, + }); + expect( + (await taskStore.list(owner.id)).find((row) => row.id === task.id), + ).toMatchObject(expected); + }); + + test("resolution keeps an unanswered final question dismissed without new durable work", async () => { + const { task, resolved, accepted } = await resolvedReply(); + expect(await accepted()).toHaveLength(1); + expect(await taskStore.get(owner.id, task.id)).toMatchObject({ + status: "resolved", + resolvedAt: resolved.resolvedAt, + }); + }); + + test("replaying an accepted reply after resolution neither accepts new work nor reopens it", async () => { + const { task, engine, input, resolved, accepted } = await resolvedReply(); + await engine.reply(owner.id, task.id, input); + await engine.idle(); + expect(await accepted()).toHaveLength(1); + expect(await taskStore.get(owner.id, task.id)).toMatchObject({ + status: "resolved", + resolvedAt: resolved.resolvedAt, + }); + }); + + test("rejected reply context neither accepts new work nor reopens a resolved task", async () => { + const { task, engine, resolved, accepted } = await resolvedReply(); + await expect( + engine.reply(owner.id, task.id, { + reply: "Use this file", + messageId: randomUUID(), + attachmentIds: [randomUUID()], + }), + ).rejects.toThrow("Files and skills are unavailable for this task."); + expect(await accepted()).toHaveLength(1); + expect(await taskStore.get(owner.id, task.id)).toMatchObject({ + status: "resolved", + resolvedAt: resolved.resolvedAt, + }); + }); + + test("a refused reply offer neither accepts new work nor reopens a resolved task", async () => { + const { task, queue, resolved, accepted } = await resolvedReply(); + const refused = engineFor(async () => {}, { + ...queue, + offer: (input) => + queue.offer({ + ...input, + atMost: { keyPrefix: `task:${task.id}:reply:`, max: 0 }, + }), + }); + await refused.engine.reply(owner.id, task.id, { + reply: "Revise it", + messageId: randomUUID(), + }); + await refused.engine.idle(); + expect(await accepted()).toHaveLength(1); + expect(await taskStore.get(owner.id, task.id)).toMatchObject({ + status: "resolved", + resolvedAt: resolved.resolvedAt, + }); + }); + + test("is worked out from the queue, the approval inbox and the question queue, never stored", async () => { + const task = await freshTask(); + // Opened, and its turn not queued yet: just started. + expect(await statusOf(task.id)).toBe("working"); + + // Its turn, queued and then running: working. + const where = await turnFor(task.id); + expect(await statusOf(task.id)).toBe("working"); + await database + .update(workItems) + .set({ + attempts: 1, + claimedBy: "worker", + leaseUntil: sql`now() + interval '5 minutes'`, + }) + .where(where); + expect(await statusOf(task.id)).toBe("working"); + // Finished: the run wrote its reply, so there is something to read. + await database + .update(workItems) + .set({ finishedAt: new Date(), claimedBy: null, leaseUntil: null }) + .where(where); + expect(await statusOf(task.id)).toBe("ready_for_review"); + + // Its reply stopped for Allow once: needs input. + const approvalId = `${prefix}-approval-${randomUUID()}`; + await database.insert(approvalRequests).values({ + id: approvalId, + ownerUserId: owner.id, + runId: `task-${task.id}`, + toolCallId: "send", + actionDigest: "digest", + action: { + threadId: task.threadId, + botId: bot, + toolRef: "google-workspace/gmail_send_reply", + } as never, + }); + expect(await statusOf(task.id)).toBe("needs_input"); + + // Allowed: carried on, so working; done, so ready to read. + await database + .update(approvalRequests) + .set({ status: "approved", decision: "allow_once" }) + .where(eq(approvalRequests.id, approvalId)); + await database.insert(workItems).values({ + kind: "approval.resume", + key: approvalId, + payload: { ownerUserId: owner.id, approvalId }, + }); + expect(await statusOf(task.id)).toBe("working"); + await database + .update(workItems) + .set({ finishedAt: new Date() }) + .where( + and( + eq(workItems.kind, "approval.resume"), + eq(workItems.key, approvalId), + ), + ); + await database + .update(approvalRequests) + .set({ status: "completed" }) + .where(eq(approvalRequests.id, approvalId)); + expect(await statusOf(task.id)).toBe("ready_for_review"); + + // A question its Bot asked in its conversation: needs input until answered. + const question = `${prefix}-question-${randomUUID()}`; + await database.insert(workItems).values({ + kind: "person.question", + key: question, + payload: { + actorId: owner.id, + botId: bot, + threadId: task.threadId, + question: "Which glaze?", + }, + }); + expect(await statusOf(task.id)).toBe("needs_input"); + await database + .update(workItems) + .set({ finishedAt: new Date() }) + .where( + and(eq(workItems.kind, "person.question"), eq(workItems.key, question)), + ); + + // Marked done: resolved. Something waiting on the person still shows over it. + await taskStore.resolve(owner.id, task.id); + expect(await statusOf(task.id)).toBe("resolved"); + await taskStore.reopen(owner.id, task.id); + expect(await statusOf(task.id)).toBe("ready_for_review"); + + // Nothing about status is a column. + const [row] = await database + .select() + .from(tasks) + .where(eq(tasks.id, task.id)); + expect(Object.keys(row ?? {})).not.toContain("status"); + }); + + test("another person's pending approval on the same thread id is not this task's", async () => { + const task = await freshTask(); + await turnFor(task.id, { attempts: 1, finishedAt: new Date() }); + await database.insert(approvalRequests).values({ + id: `${prefix}-theirs-${randomUUID()}`, + ownerUserId: other.id, + runId: "theirs", + toolCallId: "send", + actionDigest: "digest", + action: { threadId: task.threadId, botId: bot } as never, + }); + expect(await statusOf(task.id)).toBe("ready_for_review"); + }); +}); + +describe("/api/tasks", () => { + const routes = createTaskRoutes(taskStore, async (context, next) => { + context.set("actor", { + id: context.req.header("x-person") ?? owner.id, + } as AuthenticatedActor); + await next(); + }); + const call = (person: string, path: string, method = "GET") => + routes.request(path, { method, headers: { "x-person": person } }); + + test("lists the person's own tasks, newest first, and resolves and reopens them", async () => { + const first = await threads.open({ + ownerUserId: owner.id, + agentId: bot, + title: "Older", + why: null, + origin: "asked", + suggestionId: null, + sourceApp: null, + sourceLink: null, + }); + await Bun.sleep(5); + const second = await threads.open({ + ownerUserId: owner.id, + agentId: bot, + title: "Newer", + why: null, + origin: "asked", + suggestionId: null, + sourceApp: null, + sourceLink: null, + }); + const listed = (await (await call(owner.id, "/")).json()) as { + tasks: TaskRecord[]; + }; + const ids = listed.tasks.map((task) => task.id); + expect(ids.indexOf(second.id)).toBeLessThan(ids.indexOf(first.id)); + expect(listed.tasks.every((task) => typeof task.status === "string")).toBe( + true, + ); + + // Each names its conversation's thread, which its approvals and questions carry. + const conversation = await channelStore.get(owner, second.channelId); + expect(listed.tasks.find((task) => task.id === second.id)?.threadId).toBe( + conversation?.threadId ?? "missing", + ); + + await turnFor(second.id, { attempts: 1, finishedAt: new Date() }); + const resolved = (await ( + await call(owner.id, `/${second.id}/resolve`, "POST") + ).json()) as { task: TaskRecord }; + expect(resolved.task.status).toBe("resolved"); + expect(resolved.task.resolvedAt).not.toBeNull(); + const reopened = (await ( + await call(owner.id, `/${second.id}/reopen`, "POST") + ).json()) as { task: TaskRecord }; + expect(reopened.task.status).toBe("ready_for_review"); + expect(reopened.task.resolvedAt).toBeNull(); + }); + + test("another person's task is the same as no task", async () => { + const mine = await threads.open({ + ownerUserId: owner.id, + agentId: bot, + title: "Mine", + why: null, + origin: "asked", + suggestionId: null, + sourceApp: null, + sourceLink: null, + }); + expect((await call(other.id, `/${mine.id}`)).status).toBe(404); + expect((await call(other.id, `/${mine.id}/resolve`, "POST")).status).toBe( + 404, + ); + expect((await call(other.id, `/${mine.id}/reopen`, "POST")).status).toBe( + 404, + ); + const theirs = (await (await call(other.id, "/")).json()) as { + tasks: TaskRecord[]; + }; + expect(theirs.tasks).toEqual([]); + expect((await taskStore.get(owner.id, mine.id)).resolvedAt).toBeNull(); + }); +}); + +describe("a task whose turn fails", () => { + async function opened(title = `Failing ${randomUUID()}`) { + const task = await threads.open({ + ownerUserId: owner.id, + agentId: bot, + title, + why: null, + origin: "asked", + suggestionId: null, + sourceApp: null, + sourceLink: null, + }); + return task; + } + const read = (id: string, store = taskStore) => store.get(owner.id, id); + + test("reads Working while its one retry is due, then Failed with the reason in plain words, never Ready for review", async () => { + const task = await opened(); + // The first run threw: let go with its reason, and due once more. + const where = await turnFor(task.id, { + attempts: 1, + lastError: "fetch failed", + }); + expect((await read(task.id)).status).toBe("working"); + // A restart cut the first run off: the lease ran out, so the retry is due. + await database + .update(workItems) + .set({ + lastError: null, + claimedBy: "gone", + leaseUntil: sql`now() - interval '1 second'`, + }) + .where(where); + expect((await read(task.id)).status).toBe("working"); + // The retry running. + await database + .update(workItems) + .set({ + attempts: 2, + claimedBy: "worker", + leaseUntil: sql`now() + interval '3 minutes'`, + }) + .where(where); + expect((await read(task.id)).status).toBe("working"); + // The retry threw too: out of runs, so it failed, and says why without the error itself. + await database + .update(workItems) + .set({ + claimedBy: null, + leaseUntil: null, + lastError: "The routine's turn was stopped after 300s.", + }) + .where(where); + const failed = await read(task.id); + expect(failed.status).toBe("failed"); + expect(failed.failure).toBe("the run took longer than 5 minutes"); + expect(failed.failedAt).not.toBeNull(); + + // Marked done, it is done; reopened, it is failed again. + expect((await taskStore.resolve(owner.id, task.id)).status).toBe( + "resolved", + ); + // Its failure stays on the record while it is done, so a reply can still run it. + expect((await taskStore.resolve(owner.id, task.id)).failure).toBe( + "the run took longer than 5 minutes", + ); + expect((await taskStore.reopen(owner.id, task.id)).status).toBe("failed"); + + // Try again's turn is the newest, so it counts: working, then ready once it finished. + const again = await turnFor(task.id, {}, `task:${task.id}:again:1`); + expect((await read(task.id)).status).toBe("working"); + await database + .update(workItems) + .set({ attempts: 1, finishedAt: new Date() }) + .where(again); + const done = await read(task.id); + expect(done.status).toBe("ready_for_review"); + expect(done.failure).toBeNull(); + expect(done.failedAt).toBeNull(); + }); + + test("whose last run a restart cut off fails, saying so, at the moment its lease ran out", async () => { + const task = await opened(); + const expired = new Date(Date.now() - 60_000); + // The first run threw and left its error; the second was cut off, and that is what is said. + await turnFor(task.id, { + attempts: 2, + claimedBy: "gone", + leaseUntil: expired, + lastError: "fetch failed", + updatedAt: new Date(Date.now() - 4 * 60_000), + }); + expect(await read(task.id)).toMatchObject({ + status: "failed", + failure: "the run was cut off partway through", + failedAt: expired.toISOString(), + }); + }); + + test("whose turn never reached the queue fails to start, once it should have", async () => { + const task = await opened(); + expect((await read(task.id)).status).toBe("working"); + const later = createTaskStore(database, { + now: () => new Date(Date.now() + 2 * 60_000), + }); + expect(await read(task.id, later)).toMatchObject({ + status: "failed", + failure: "it never started", + }); + }); + + test("stays Failed whatever is said in its conversation, until a turn of its own finishes", async () => { + const task = await opened(); + await turnFor(task.id, { attempts: 2, lastError: "fetch failed" }); + await channelStore.recordActivity( + owner, + task.channelId, + { + text: "I couldn't finish this: a service didn't answer.", + agentId: bot, + at: new Date(), + }, + { id: `task-${task.id}-x:failed` }, + ); + // A Bot answer from the browser is not the task's turn: it may not have had the task at all. + await channelStore.recordActivity(owner, task.channelId, { + text: "Try it with the other glaze.", + agentId: null, + at: new Date(Date.now() + 1_000), + }); + await channelStore.recordActivity(owner, task.channelId, { + text: "Evening. Here's what's in your inbox.", + agentId: bot, + at: new Date(Date.now() + 2_000), + }); + expect((await read(task.id)).status).toBe("failed"); + // Its own turn finishing is. + const again = await turnFor(task.id, {}, `task:${task.id}:again:1`); + await database + .update(workItems) + .set({ attempts: 1, finishedAt: new Date() }) + .where(again); + expect((await read(task.id)).status).toBe("ready_for_review"); + }); + + test("runs once more after throwing, and the second run is its last", async () => { + const { engine, items, started } = engineFor(async () => { + throw new Error("The routine's turn was stopped after 300s."); + }); + const task = await engine.startAsked(owner.id, bot, { + title: "Prepare notes for the Hearth & Co visit", + instruction: "Prepare notes for the Hearth & Co visit.", + }); + await engine.idle(); + expect(started.map((run) => run.final)).toEqual([false, true]); + expect(started[0]?.runId).toBe(`task-${task.id}`); + // The retry is a run of its own, so it never collides with the first in the conversation. + expect(started[1]?.runId).toStartWith(`task-${task.id}-`); + expect(started[1]?.runId).not.toBe(started[0]?.runId); + expect(started[1]?.text).toBe(started[0]?.text); + // Out of runs: unfinished on the queue, with its reason, which is what reads as Failed. + expect( + items.get(`${PROACTIVE_TASK_KIND}:task:${task.id}`)?.payload, + ).toMatchObject({ runs: 2 }); + expect(items.get(`${PROACTIVE_TASK_KIND}:task:${task.id}`)).toMatchObject({ + attempts: 2, + finished: false, + lastError: "The routine's turn was stopped after 300s.", + }); + }); + + test("whose first run a restart cut off is picked up by the sweep, as its last run", async () => { + const task = await opened(); + const { engine, items, started } = engineFor(); + // Claimed once by a process that died: its lease has run out. + items.set(`${PROACTIVE_TASK_KIND}:task:${task.id}`, { + kind: PROACTIVE_TASK_KIND, + key: `task:${task.id}`, + payload: { + ownerUserId: owner.id, + taskId: task.id, + text: "Go on.", + runs: 2, + }, + attempts: 1, + finished: false, + claimed: false, + }); + await engine.sweep(); + expect(started).toHaveLength(1); + expect(started[0]?.final).toBe(true); + expect(started[0]?.runId).toStartWith(`task-${task.id}-`); + expect(started[0]?.text).toBe(frameTaskBrief("Go on.")); + expect(items.get(`${PROACTIVE_TASK_KIND}:task:${task.id}`)?.finished).toBe( + true, + ); + }); + + test("a turn from before retries is never run a second time, and stays failed", async () => { + const task = await opened(); + const { engine, items, started } = engineFor(); + // Failed once, when one failure was final, and left unfinished. + items.set(`${PROACTIVE_TASK_KIND}:task:${task.id}`, { + kind: PROACTIVE_TASK_KIND, + key: `task:${task.id}`, + payload: { ownerUserId: owner.id, taskId: task.id, text: "Go on." }, + attempts: 1, + finished: false, + claimed: false, + }); + await engine.sweep().catch(() => undefined); + expect(started).toHaveLength(0); + expect(items.get(`${PROACTIVE_TASK_KIND}:task:${task.id}`)).toMatchObject({ + attempts: 2, + finished: false, + lastError: "This turn failed before it could be tried again.", + }); + }); + + test("a research run that throws in the sweep does not hold back a task's turn", async () => { + const task = await opened(); + const { engine, items, started } = engineFor(); + items.set(`${PROACTIVE_RUN_KIND}:broken`, { + kind: PROACTIVE_RUN_KIND, + key: "broken", + payload: {}, + attempts: 0, + finished: false, + claimed: false, + }); + items.set(`${PROACTIVE_TASK_KIND}:task:${task.id}`, { + kind: PROACTIVE_TASK_KIND, + key: `task:${task.id}`, + payload: { + ownerUserId: owner.id, + taskId: task.id, + text: "Go on.", + runs: 2, + }, + attempts: 0, + finished: false, + claimed: false, + }); + await expect(engine.sweep()).rejects.toThrow(); + expect(started).toHaveLength(1); + expect(items.get(`${PROACTIVE_TASK_KIND}:task:${task.id}`)?.finished).toBe( + true, + ); + }); + + test("a reply on a failed task is Try again: the person's own message, with the ask beside it for the model", async () => { + const task = await opened(); + await turnFor(task.id, { attempts: 2, lastError: "fetch failed" }); + const { engine, items, started } = engineFor(); + await engine.again(owner.id, task.id, { + reply: "Use the Oat Satin glaze.", + messageId: "reply-0123456789", + }); + await engine.idle(); + expect(started).toHaveLength(1); + expect(started[0]?.text).toBe( + frameTaskReply("Prepare the notes.", "Use the Oat Satin glaze."), + ); + // Their bubble is their words, under the id the browser already drew it with. + expect(readTaskBrief(started[0]?.text ?? "")).toBe( + "Use the Oat Satin glaze.", + ); + expect(started[0]?.runId).toBe("reply-0123456789"); + expect( + items.get(`${PROACTIVE_TASK_KIND}:task:${task.id}:again:1`)?.payload, + ).toMatchObject({ + reply: "Use the Oat Satin glaze.", + again: true, + runs: 2, + }); + }); + + test.each(["Review the blue ledger.", ""])( + "Try again preserves the exhausted reply envelope for text %j", + async (text) => { + const task = await opened(); + const questionId = `accepted-question-${randomUUID()}`; + const futureQuestionId = `future-question-${randomUUID()}`; + const question = { + ownerUserId: owner.id, + actorId: owner.id, + channelId: task.channelId, + mode: "completed_question", + }; + await database.insert(workItems).values({ + kind: "person.question", + key: questionId, + payload: question, + }); + const envelope = { + text, + reply: text || "Sent attachments", + attachmentIds: ["12345678-1234-4123-8123-123456789abc"], + skillIds: ["review-blue-ledger"], + followup: true, + questionIds: [questionId], + }; + const messageId = `accepted-reply-${randomUUID()}`; + await turnFor(task.id, { + payload: { + ownerUserId: owner.id, + taskId: task.id, + ...envelope, + messageId, + runs: 2, + }, + attempts: 2, + lastError: "Failed before input persistence", + }); + expect(await taskStore.failedTurn(owner.id, task.id)).toEqual({ + ask: envelope, + turns: 1, + }); + let attempts = 0; + const { engine, items, started } = engineFor(async () => { + if (++attempts === 1) { + await database.insert(workItems).values({ + kind: "person.question", + key: futureQuestionId, + payload: question, + }); + throw new Error("Retry before delivering the preserved input"); + } + }); + await engine.again(owner.id, task.id); + await engine.idle(); + expect( + items.get(`${PROACTIVE_TASK_KIND}:task:${task.id}:again:1`)?.payload, + ).toEqual({ + ownerUserId: owner.id, + taskId: task.id, + ...envelope, + again: true, + runs: 2, + }); + expect(started).toHaveLength(2); + for (const run of started) { + expect(run).toMatchObject({ + text, + attachmentIds: envelope.attachmentIds, + skillIds: envelope.skillIds, + }); + expect(run.runId).not.toBe(messageId); + } + expect(started[0]!.runId).not.toBe(started[1]!.runId); + expect(await taskStore.pendingQuestions(owner.id, task.id)).toEqual([ + futureQuestionId, + ]); + }, + ); + + test.each(["asked", "suggestion"])( + "successive initial %s corrections survive absent model history", + async (origin) => { + let failing = true; + const persisted: unknown[] = []; + const received: string[] = []; + const taskTurn = createTaskTurn({ + actorFor, + channels: channelStore, + runTurn: async (input) => { + if (failing) throw new Error("Failed before history persistence"); + received.push(input.instruction); + persisted.push(input.userMessage); + return { replyText: "" }; + }, + }); + const { engine, started } = engineFor( + async ({ scope, ...input }, signal) => { + await taskTurn(scope, { ...input, signal }); + }, + createWorkQueue(database), + ); + const suggestion = + origin === "suggestion" ? await suggestionFor("Draft the email") : null; + const task = suggestion + ? (await engine.start(owner.id, suggestion.id)).task + : await engine.startAsked(owner.id, bot, { + title: "Draft the email", + instruction: "Draft the email", + }); + await engine.idle(); + await engine.again(owner.id, task.id, { reply: "Write it in French" }); + await engine.idle(); + const { ask } = await taskStore.failedTurn(owner.id, task.id); + expect(ask).toMatchObject({ + reply: "Write it in French", + ...(suggestion + ? { suggestionId: suggestion.id } + : { text: "Draft the email" }), + }); + expect({ persisted, attempts: started.length }).toEqual({ + persisted: [], + attempts: 4, + }); + failing = false; + const messageId = randomUUID(); + await engine.again(owner.id, task.id, { + reply: "Use a formal tone", + messageId, + }); + await engine.idle(); + expect( + received.map((text) => + text.match(/Draft the email|Write it in French|Use a formal tone/g), + ), + ).toEqual([ + ["Draft the email", "Write it in French", "Use a formal tone"], + ]); + expect({ + final: started.slice(4), + identities: new Set(started.map((run) => run.runId)).size, + }).toMatchObject({ + final: [{ runId: messageId, scope: { channelId: task.channelId } }], + identities: 5, + }); + }, + ); + + test("successive corrections preserve failed followup context and display only the latest reply", async () => { + const task = await opened(); + const questionId = `accepted-question-${randomUUID()}`; + const context = { + attachmentIds: ["12345678-1234-4123-8123-123456789abc"], + skillIds: ["review-blue-ledger"], + followup: true, + questionIds: [questionId], + }; + await database.insert(workItems).values({ + kind: "person.question", + key: questionId, + payload: { + ownerUserId: owner.id, + actorId: owner.id, + channelId: task.channelId, + mode: "completed_question", + }, + }); + const original = "Review the blue ledger."; + await turnFor(task.id, { + payload: { + ownerUserId: owner.id, + taskId: task.id, + text: original, + reply: original, + ...context, + runs: 2, + }, + attempts: 2, + lastError: "fetch failed", + }); + const { engine, started } = engineFor(async () => { + throw new Error("fetch failed"); + }, createWorkQueue(database)); + const corrections = [ + "Use the revised totals.", + `Use October's exchange rate. Keep this quote:\n\n${TASK_REPLY_LINE}\n\nQuoted example.`, + ]; + const earlier = [original]; + for (const [index, reply] of corrections.entries()) { + const messageId = `revised-reply-${index}`; + await engine.again(owner.id, task.id, { reply, messageId }); + await engine.idle(); + expect(started).toHaveLength((index + 1) * 2); + expect(started[index * 2]!.runId).toBe(messageId); + const failed = await taskStore.failedTurn(owner.id, task.id); + expect(failed).toMatchObject({ + ask: { ...context, reply }, + turns: index + 2, + }); + for (const run of started.slice(index * 2)) { + expect(run).toMatchObject({ + attachmentIds: context.attachmentIds, + skillIds: context.skillIds, + }); + for (const text of earlier) expect(run.text).toContain(text); + expect(readTaskBrief(run.text)).toBe(reply); + } + earlier.push(reply); + } + expect(await taskStore.pendingQuestions(owner.id, task.id)).toEqual([]); + }); + + test("accepted replies appear once across retries and Try again, including equal-text corrections", async () => { + const task = await opened(); + const reply = "Use October's exchange rate."; + const messages: ReportMessage[] = [ + { + id: "initial", + role: "user", + content: frameTaskBrief("Prepare the notes."), + }, + ]; + const replyCounts: number[] = []; + const received: Parameters[0][] = []; + const context = replyContextForTest(); + const taskTurn = createTaskTurn({ + actorFor, + channels: channelStore, + replyContext: context, + runTurn: async (input) => { + if (input.userMessage?.role !== "user") + throw new Error("Task user message is missing"); + received.push(input); + messages.push(input.userMessage); + messages.push({ + id: `${input.routineId}:outcome`, + role: "assistant", + content: "Still unavailable.", + }); + replyCounts.push( + report().entries.filter((entry) => entry.kind === "reply").length, + ); + throw new Error("fetch failed"); + }, + }); + const report = () => + buildTaskReport({ + task: { id: task.id, status: "failed" }, + messages, + approvals: [], + firstTurnEndedAt: null, + resumedEndedAt: new Map(), + lastMessageAt: null, + }); + const { engine, started } = engineFor( + async ({ scope, ...input }, signal) => { + await taskTurn(scope, { ...input, signal }); + }, + createWorkQueue(database), + context, + ); + await engine.reply(owner.id, task.id, { reply, messageId: "accepted-a" }); + await engine.idle(); + await engine.again(owner.id, task.id); + await engine.idle(); + await engine.again(owner.id, task.id, { reply, messageId: "accepted-b" }); + await engine.idle(); + expect(replyCounts).toEqual([1, 1, 1, 1, 2, 2]); + expect(new Set(started.map((run) => run.runId)).size).toBe(6); + expect(started[0]?.runId).toBe("accepted-a"); + expect(started[4]?.runId).toBe("accepted-b"); + expect(started.map((run) => run.acceptedReply?.id)).toEqual([ + "accepted-a", + "accepted-a", + "accepted-a", + "accepted-a", + "accepted-b", + "accepted-b", + ]); + expect( + messages + .filter((message) => message.role === "user") + .slice(1) + .map((message) => readTaskFrame(String(message.content))?.inputId), + ).toEqual(started.map((run) => run.acceptedReply?.id)); + expect(report().entries.map((entry) => entry.kind)).toEqual([ + "reply", + "update", + "update", + "update", + "update", + "reply", + "update", + "update", + ]); + expect(report().entries.filter((entry) => entry.kind === "reply")).toEqual([ + { kind: "reply", id: "accepted-a", text: reply, at: null }, + { kind: "reply", id: "accepted-b", text: reply, at: null }, + ]); + const quoted = frameTaskBrief("quoted task text"); + await engine.reply(owner.id, task.id, { + reply: quoted, + messageId: "accepted-quote", + }); + await engine.idle(); + const quotedInputs = messages + .filter((message) => message.role === "user") + .slice(-2); + for (const message of quotedInputs) + expect(readTaskBrief(String(message.content))).toBe(quoted); + expect( + report().entries.filter((entry) => entry.kind === "reply"), + ).toHaveLength(3); + const file = await attachmentForTask(task.channelId); + await engine.reply(owner.id, task.id, { + reply: "", + messageId: "accepted-file", + attachmentIds: [file.id], + }); + await engine.idle(); + const fileInputs = received.slice(-2); + expect( + fileInputs.map( + (input) => readTaskFrame(textOf(input.userMessage?.content))?.inputId, + ), + ).toEqual(["accepted-file", "accepted-file"]); + expect(replyCounts.slice(-2).map((count) => count - 3)).toEqual([1, 1]); + expect( + report() + .entries.filter((entry) => entry.kind === "reply") + .slice(3) + .map((entry) => entry.text), + ).toEqual(["Sent attachments"]); + const document = { + type: "document", + source: { + type: "url", + value: `/api/attachments/${file.id}`, + mimeType: "text/plain", + }, + metadata: { attachmentId: file.id, filename: "notes.txt" }, + }; + expect( + fileInputs.map((input) => { + const content = input.userMessage?.content; + return Array.isArray(content) + ? content.filter((part) => part.type !== "text") + : content; + }), + ).toEqual([[document], [document]]); + expect(fileInputs.map((input) => input.instruction)).toEqual(["", ""]); + }); + + test("marked done after failing, it keeps its failure, and a reply runs it and makes it not done", async () => { + const task = await opened(); + await turnFor(task.id, { attempts: 2, lastError: "fetch failed" }); + const done = await taskStore.resolve(owner.id, task.id); + expect(done).toMatchObject({ + status: "resolved", + failure: "a service didn't answer", + }); + expect(done.failedAt).not.toBeNull(); + const queue = createWorkQueue(database); + const { engine, started } = engineFor(async () => {}, queue); + await engine.again(owner.id, task.id, { reply: "Please do it now." }); + await engine.idle(); + expect(started).toHaveLength(1); + expect((await taskStore.get(owner.id, task.id)).resolvedAt).toBeNull(); + // Done and never failed has nothing to try again. + const fine = await opened(); + await turnFor(fine.id, { attempts: 1, finishedAt: new Date() }); + await taskStore.resolve(owner.id, fine.id); + await expect(engine.again(owner.id, fine.id)).rejects.toThrow( + "Only a task that couldn't finish can be tried again.", + ); + }); + + test("a refused Try again offer preserves the failed task's completion", async () => { + const task = await opened(); + await turnFor(task.id, { attempts: 2, lastError: "fetch failed" }); + const done = await taskStore.resolve(owner.id, task.id); + const queue = createWorkQueue(database); + const { engine } = engineFor(async () => {}, { + ...queue, + offer: (input) => + queue.offer({ + ...input, + atMost: { keyPrefix: `task:${task.id}:again:`, max: 0 }, + }), + }); + await expect(engine.again(owner.id, task.id)).rejects.toThrow( + "This task is already being tried again.", + ); + const turns = await database + .select() + .from(workItems) + .where(sql`${workItems.payload}->>'taskId' = ${task.id}`); + expect(turns).toHaveLength(1); + const [persisted] = await database + .select({ resolvedAt: tasks.resolvedAt }) + .from(tasks) + .where(eq(tasks.id, task.id)); + expect(persisted?.resolvedAt?.toISOString() ?? null).toBe(done.resolvedAt); + const expected = { status: "resolved", resolvedAt: done.resolvedAt }; + const detail = await taskStore.get(owner.id, task.id); + const listed = (await taskStore.list(owner.id)).find( + (row) => row.id === task.id, + ); + expect([detail, listed]).toMatchObject([expected, expected]); + }); + + test("a turn shaped as main queued it, from before tasks and retries, opens nothing and never runs again", async () => { + const { engine, items, started } = engineFor(); + const suggestionId = `${prefix}-main-suggestion-${randomUUID()}`; + // Main's Start: keyed by the suggestion, no task, no run count, failed once and left. + items.set(`${PROACTIVE_TASK_KIND}:${suggestionId}`, { + kind: PROACTIVE_TASK_KIND, + key: suggestionId, + payload: { ownerUserId: owner.id, suggestionId }, + attempts: 1, + finished: false, + claimed: false, + }); + const tasksBefore = await database + .select({ id: tasks.id }) + .from(tasks) + .where(eq(tasks.ownerUserId, owner.id)); + await engine.sweep().catch(() => undefined); + const tasksAfter = await database + .select({ id: tasks.id }) + .from(tasks) + .where(eq(tasks.ownerUserId, owner.id)); + expect(tasksAfter).toHaveLength(tasksBefore.length); + expect(started).toHaveLength(0); + expect(items.get(`${PROACTIVE_TASK_KIND}:${suggestionId}`)).toMatchObject({ + attempts: 2, + finished: false, + lastError: "This turn failed before it could be tried again.", + }); + }); + + test("Try again runs it once more in its own conversation, and only when it failed", async () => { + const task = await opened(); + await turnFor(task.id, { attempts: 2, lastError: "fetch failed" }); + const { engine, items, started } = engineFor(); + + // Somebody else's task is no task. + await expect(engine.again(other.id, task.id)).rejects.toThrow( + "Couldn't find that task.", + ); + // Pressed twice at once, it is one run, and the second is told so rather than answered as done. + const both = await Promise.allSettled([ + engine.again(owner.id, task.id), + engine.again(owner.id, task.id), + ]); + expect(both.map((outcome) => outcome.status).sort()).toEqual([ + "fulfilled", + "rejected", + ]); + expect( + both.find((outcome) => outcome.status === "rejected")?.reason, + ).toBeInstanceOf(ProactiveRefusedError); + await engine.idle(); + expect(started).toHaveLength(1); + expect(started[0]?.scope.channelId).toBe(task.channelId); + expect(started[0]?.text).toBe(frameTaskBrief("Prepare the notes.")); + expect(started[0]?.runId).toStartWith(`task-${task.id}-`); + expect(started[0]?.final).toBe(false); + expect( + items.get(`${PROACTIVE_TASK_KIND}:task:${task.id}:again:1`)?.finished, + ).toBe(true); + + // One that did not fail has nothing to try again. + const fine = await opened(); + await turnFor(fine.id, { attempts: 1, finishedAt: new Date() }); + await expect(engine.again(owner.id, fine.id)).rejects.toThrow( + "Only a task that couldn't finish can be tried again.", + ); + }); + + test("Try again is served to the task's owner, for a failed task only", async () => { + const task = await opened(); + await turnFor(task.id, { attempts: 2, lastError: "fetch failed" }); + const fine = await opened(); + await turnFor(fine.id, { attempts: 1, finishedAt: new Date() }); + const { engine, started } = engineFor(); + const routes = createTaskRoutes( + taskStore, + async (context, next) => { + context.set("actor", { + id: context.req.header("x-person") ?? owner.id, + } as AuthenticatedActor); + await next(); + }, + { retry: engine }, + ); + const again = (person: string, id: string) => + routes.request(`/${id}/again`, { + method: "POST", + headers: { "x-person": person }, + }); + + expect((await again(other.id, task.id)).status).toBe(404); + const refused = await again(owner.id, fine.id); + expect(refused.status).toBe(409); + expect(((await refused.json()) as { error: string }).error).toBe( + "Only a task that couldn't finish can be tried again.", + ); + const tooLong = await routes.request(`/${task.id}/again`, { + method: "POST", + headers: { "x-person": owner.id, "content-type": "application/json" }, + body: JSON.stringify({ reply: "x".repeat(4001) }), + }); + expect(tooLong.status).toBe(400); + const badId = await routes.request(`/${task.id}/again`, { + method: "POST", + headers: { "x-person": owner.id, "content-type": "application/json" }, + body: JSON.stringify({ reply: "Hi", messageId: "