diff --git a/drizzle/0008_old_ken_ellis.sql b/drizzle/0008_old_ken_ellis.sql new file mode 100644 index 000000000..c852e79b1 --- /dev/null +++ b/drizzle/0008_old_ken_ellis.sql @@ -0,0 +1,40 @@ +CREATE TABLE IF NOT EXISTS `automation_run_logs` ( + `id` text PRIMARY KEY NOT NULL, + `automation_id` text NOT NULL, + `started_at` text NOT NULL, + `finished_at` text, + `status` text NOT NULL, + `error` text, + `task_id` text, + FOREIGN KEY (`automation_id`) REFERENCES `automations`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`task_id`) REFERENCES `tasks`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `automations` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `project_name` text DEFAULT '' NOT NULL, + `name` text NOT NULL, + `prompt` text NOT NULL, + `agent_id` text NOT NULL, + `mode` text DEFAULT 'schedule' NOT NULL, + `schedule` text NOT NULL, + `trigger_type` text, + `trigger_config` text, + `use_worktree` integer DEFAULT 1 NOT NULL, + `status` text DEFAULT 'active' NOT NULL, + `last_run_at` text, + `next_run_at` text, + `run_count` integer DEFAULT 0 NOT NULL, + `last_run_result` text, + `last_run_error` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_automation_run_logs_automation_started` ON `automation_run_logs` (`automation_id`,`started_at`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_automation_run_logs_status` ON `automation_run_logs` (`status`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_automations_project_id` ON `automations` (`project_id`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_automations_status_next_run` ON `automations` (`status`,`next_run_at`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_automations_updated_at` ON `automations` (`updated_at`); \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 000000000..a09d7bf22 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1439 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9665f2b2-22d7-4394-9f46-64f01a7337ba", + "prevId": "fc1e0fd4-cacb-454c-b016-730f5d794f02", + "tables": { + "app_settings": { + "name": "app_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_app_settings_key": { + "name": "idx_app_settings_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "automation_run_logs": { + "name": "automation_run_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_run_logs_automation_started": { + "name": "idx_automation_run_logs_automation_started", + "columns": ["automation_id", "started_at"], + "isUnique": false + }, + "idx_automation_run_logs_status": { + "name": "idx_automation_run_logs_status", + "columns": ["status"], + "isUnique": false + } + }, + "foreignKeys": { + "automation_run_logs_automation_id_automations_id_fk": { + "name": "automation_run_logs_automation_id_automations_id_fk", + "tableFrom": "automation_run_logs", + "tableTo": "automations", + "columnsFrom": ["automation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_run_logs_task_id_tasks_id_fk": { + "name": "automation_run_logs_task_id_tasks_id_fk", + "tableFrom": "automation_run_logs", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "automations": { + "name": "automations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'schedule'" + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger_config": { + "name": "trigger_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_worktree": { + "name": "use_worktree", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "last_run_at": { + "name": "last_run_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_run_result": { + "name": "last_run_result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_error": { + "name": "last_run_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_automations_project_id": { + "name": "idx_automations_project_id", + "columns": ["project_id"], + "isUnique": false + }, + "idx_automations_status_next_run": { + "name": "idx_automations_status_next_run", + "columns": ["status", "next_run_at"], + "isUnique": false + }, + "idx_automations_updated_at": { + "name": "idx_automations_updated_at", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "automations_project_id_projects_id_fk": { + "name": "automations_project_id_projects_id_fk", + "tableFrom": "automations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_conversations_task_id": { + "name": "idx_conversations_task_id", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_task_id_tasks_id_fk": { + "name": "conversations_task_id_tasks_id_fk", + "tableFrom": "conversations", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "editor_buffers": { + "name": "editor_buffers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_editor_buffers_task_file": { + "name": "idx_editor_buffers_task_file", + "columns": ["task_id", "file_path"], + "isUnique": false + } + }, + "foreignKeys": { + "editor_buffers_project_id_projects_id_fk": { + "name": "editor_buffers_project_id_projects_id_fk", + "tableFrom": "editor_buffers", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "editor_buffers_task_id_tasks_id_fk": { + "name": "editor_buffers_task_id_tasks_id_fk", + "tableFrom": "editor_buffers", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "kv": { + "name": "kv", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_kv_key": { + "name": "idx_kv_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "line_comments": { + "name": "line_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line_content": { + "name": "line_content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "sent_at": { + "name": "sent_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_line_comments_task_file": { + "name": "idx_line_comments_task_file", + "columns": ["task_id", "file_path"], + "isUnique": false + } + }, + "foreignKeys": { + "line_comments_task_id_tasks_id_fk": { + "name": "line_comments_task_id_tasks_id_fk", + "tableFrom": "line_comments", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender": { + "name": "sender", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_messages_conversation_id": { + "name": "idx_messages_conversation_id", + "columns": ["conversation_id"], + "isUnique": false + }, + "idx_messages_timestamp": { + "name": "idx_messages_timestamp", + "columns": ["timestamp"], + "isUnique": false + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "project_pull_requests": { + "name": "project_pull_requests", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pull_request_url": { + "name": "pull_request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_project_pull_requests_project_id": { + "name": "idx_project_pull_requests_project_id", + "columns": ["project_id"], + "isUnique": false + } + }, + "foreignKeys": { + "project_pull_requests_project_id_projects_id_fk": { + "name": "project_pull_requests_project_id_projects_id_fk", + "tableFrom": "project_pull_requests", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_pull_requests_pull_request_url_pull_requests_url_fk": { + "name": "project_pull_requests_pull_request_url_pull_requests_url_fk", + "tableFrom": "project_pull_requests", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_url"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_pull_requests_project_id_pull_request_url_pk": { + "columns": ["project_id", "pull_request_url"], + "name": "project_pull_requests_project_id_pull_request_url_pk" + } + }, + "uniqueConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_provider": { + "name": "workspace_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "base_ref": { + "name": "base_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_connection_id": { + "name": "ssh_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_projects_path": { + "name": "idx_projects_path", + "columns": ["path"], + "isUnique": true + }, + "idx_projects_ssh_connection_id": { + "name": "idx_projects_ssh_connection_id", + "columns": ["ssh_connection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "projects_ssh_connection_id_ssh_connections_id_fk": { + "name": "projects_ssh_connection_id_ssh_connections_id_fk", + "tableFrom": "projects", + "tableTo": "ssh_connections", + "columnsFrom": ["ssh_connection_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pull_request_assignees": { + "name": "pull_request_assignees", + "columns": { + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_pra_login": { + "name": "idx_pra_login", + "columns": ["login"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_assignees_pull_request_id_pull_requests_id_fk": { + "name": "pull_request_assignees_pull_request_id_pull_requests_id_fk", + "tableFrom": "pull_request_assignees", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pull_request_assignees_pull_request_id_login_pk": { + "columns": ["pull_request_id", "login"], + "name": "pull_request_assignees_pull_request_id_login_pk" + } + }, + "uniqueConstraints": {} + }, + "pull_request_labels": { + "name": "pull_request_labels", + "columns": { + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_prl_name": { + "name": "idx_prl_name", + "columns": ["name"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_labels_pull_request_id_pull_requests_id_fk": { + "name": "pull_request_labels_pull_request_id_pull_requests_id_fk", + "tableFrom": "pull_request_labels", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pull_request_labels_pull_request_id_name_pk": { + "columns": ["pull_request_id", "name"], + "name": "pull_request_labels_pull_request_id_name_pk" + } + }, + "uniqueConstraints": {} + }, + "pull_requests": { + "name": "pull_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'github'" + }, + "name_with_owner": { + "name": "name_with_owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_display_name": { + "name": "author_display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_avatar_url": { + "name": "author_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_draft": { + "name": "is_draft", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "head_ref_name": { + "name": "head_ref_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "fetched_at": { + "name": "fetched_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_pull_requests_url": { + "name": "idx_pull_requests_url", + "columns": ["url"], + "isUnique": true + }, + "idx_pull_requests_name_with_owner": { + "name": "idx_pull_requests_name_with_owner", + "columns": ["name_with_owner"], + "isUnique": false + }, + "idx_pull_requests_author_login": { + "name": "idx_pull_requests_author_login", + "columns": ["author_login"], + "isUnique": false + }, + "idx_pull_requests_head_ref_name": { + "name": "idx_pull_requests_head_ref_name", + "columns": ["head_ref_name"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "ssh_connections": { + "name": "ssh_connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 22 + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "private_key_path": { + "name": "private_key_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_agent": { + "name": "use_agent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_connections_name": { + "name": "idx_ssh_connections_name", + "columns": ["name"], + "isUnique": true + }, + "idx_ssh_connections_host": { + "name": "idx_ssh_connections_host", + "columns": ["host"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_branch": { + "name": "source_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_branch": { + "name": "task_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_issue": { + "name": "linked_issue", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_interacted_at": { + "name": "last_interacted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_tasks_project_id": { + "name": "idx_tasks_project_id", + "columns": ["project_id"], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "tasks_pull_requests": { + "name": "tasks_pull_requests", + "columns": { + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pull_request_url": { + "name": "pull_request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tasks_pull_requests_task_id_tasks_id_fk": { + "name": "tasks_pull_requests_task_id_tasks_id_fk", + "tableFrom": "tasks_pull_requests", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_pull_requests_pull_request_url_pull_requests_url_fk": { + "name": "tasks_pull_requests_pull_request_url_pull_requests_url_fk", + "tableFrom": "tasks_pull_requests", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_url"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tasks_pull_requests_task_id_pull_request_url_pk": { + "columns": ["task_id", "pull_request_url"], + "name": "tasks_pull_requests_task_id_pull_request_url_pk" + } + }, + "uniqueConstraints": {} + }, + "terminals": { + "name": "terminals", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh": { + "name": "ssh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_terminals_task_id": { + "name": "idx_terminals_task_id", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "terminals_project_id_projects_id_fk": { + "name": "terminals_project_id_projects_id_fk", + "tableFrom": "terminals", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminals_task_id_tasks_id_fk": { + "name": "terminals_task_id_tasks_id_fk", + "tableFrom": "terminals", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f9fe048dc..068f8b4dd 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1775660444488, "tag": "0007_serious_ben_grimm", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1775724321439, + "tag": "0008_old_ken_ellis", + "breakpoints": true } ] } diff --git a/src/assets/images/Sentry.svg b/src/assets/images/Sentry.svg new file mode 100644 index 000000000..9b1b7fe0d --- /dev/null +++ b/src/assets/images/Sentry.svg @@ -0,0 +1 @@ +Sentry diff --git a/src/main/core/agent-hooks/hook-config.ts b/src/main/core/agent-hooks/hook-config.ts index 25b1336d3..c0f3a0a03 100644 --- a/src/main/core/agent-hooks/hook-config.ts +++ b/src/main/core/agent-hooks/hook-config.ts @@ -3,7 +3,6 @@ import { resolveCommandPath } from '@main/core/dependencies/probe'; import type { FileSystemProvider } from '@main/core/fs/types'; import type { ExecFn } from '@main/core/utils/exec'; import { log } from '@main/lib/logger'; -import { ExecFn } from '../utils/exec'; const EMDASH_MARKER = 'EMDASH_HOOK_PORT'; diff --git a/src/main/core/automations/automations-service.ts b/src/main/core/automations/automations-service.ts new file mode 100644 index 000000000..1ea6df0d1 --- /dev/null +++ b/src/main/core/automations/automations-service.ts @@ -0,0 +1,724 @@ +import crypto from 'node:crypto'; +import { and, desc, eq, lte } from 'drizzle-orm'; +import { isValidProviderId, type AgentProviderId } from '@shared/agent-provider-registry'; +import type { + Automation, + AutomationRunLog, + AutomationSchedule, + CreateAutomationInput, + DayOfWeek, + TriggerType, + UpdateAutomationInput, +} from '@shared/automations/types'; +import { taskCreatedExternallyChannel } from '@shared/events/appEvents'; +import { forgejoService } from '@main/core/forgejo/forgejo-service'; +import { issueService } from '@main/core/github/services/issue-service'; +import { gitlabService } from '@main/core/gitlab/gitlab-service'; +import JiraService from '@main/core/jira/JiraService'; +import { linearService } from '@main/core/linear/LinearService'; +import { plainService } from '@main/core/plain/plain-service'; +import { projectManager } from '@main/core/projects/project-manager'; +import { prService } from '@main/core/pull-requests/pr-service'; +import { createTask } from '@main/core/tasks/createTask'; +import { db } from '@main/db/client'; +import { automationRunLogs, automations, projects } from '@main/db/schema'; +import { events } from '@main/lib/events'; +import { log } from '@main/lib/logger'; + +type RawEvent = { + id: string; + title: string; + url?: string; + labels?: string[]; + assignee?: string; + branch?: string; + createdAt?: string; +}; + +const DAY_ORDER: DayOfWeek[] = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; + +function computeNextRun(schedule: AutomationSchedule, fromDate = new Date()): string { + const now = new Date(fromDate); + const next = new Date(now); + const hour = schedule.hour ?? 0; + const minute = schedule.minute ?? 0; + + switch (schedule.type) { + case 'hourly': + next.setMinutes(minute, 0, 0); + if (next <= now) next.setHours(next.getHours() + 1); + break; + case 'daily': + next.setHours(hour, minute, 0, 0); + if (next <= now) next.setDate(next.getDate() + 1); + break; + case 'weekly': { + const target = DAY_ORDER.indexOf(schedule.dayOfWeek ?? 'mon'); + const current = next.getDay(); + let delta = target - current; + if (delta < 0) delta += 7; + next.setDate(next.getDate() + delta); + next.setHours(hour, minute, 0, 0); + if (next <= now) next.setDate(next.getDate() + 7); + break; + } + case 'monthly': { + const desiredDay = schedule.dayOfMonth ?? 1; + const monthDays = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate(); + next.setDate(Math.min(desiredDay, monthDays)); + next.setHours(hour, minute, 0, 0); + if (next <= now) { + next.setMonth(next.getMonth() + 1); + const nextMonthDays = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate(); + next.setDate(Math.min(desiredDay, nextMonthDays)); + } + break; + } + } + + return next.toISOString(); +} + +function parseNameWithOwner(remote?: string | null): string | null { + if (!remote) return null; + const ssh = /^git@[^:]+:(.+?)(?:\.git)?$/.exec(remote); + if (ssh?.[1]) return ssh[1]; + const https = /^https?:\/\/[^/]+\/(.+?)(?:\.git)?$/.exec(remote); + if (https?.[1]) return https[1]; + return null; +} + +function mapAutomation(row: typeof automations.$inferSelect): Automation { + return { + id: row.id, + projectId: row.projectId, + projectName: row.projectName, + name: row.name, + prompt: row.prompt, + agentId: row.agentId, + mode: row.mode === 'trigger' ? 'trigger' : 'schedule', + schedule: JSON.parse(row.schedule) as AutomationSchedule, + triggerType: (row.triggerType as TriggerType | null) ?? null, + triggerConfig: row.triggerConfig ? JSON.parse(row.triggerConfig) : null, + useWorktree: row.useWorktree === 1, + status: row.status as Automation['status'], + lastRunAt: row.lastRunAt, + nextRunAt: row.nextRunAt, + runCount: row.runCount, + lastRunResult: (row.lastRunResult as 'success' | 'failure' | null) ?? null, + lastRunError: row.lastRunError, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function mapRun(row: typeof automationRunLogs.$inferSelect): AutomationRunLog { + return { + id: row.id, + automationId: row.automationId, + startedAt: row.startedAt, + finishedAt: row.finishedAt, + status: row.status as AutomationRunLog['status'], + error: row.error, + taskId: row.taskId, + }; +} + +function slug(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') + .slice(0, 30); +} + +export class AutomationsService { + private scheduleTimer: NodeJS.Timeout | null = null; + private triggerTimer: NodeJS.Timeout | null = null; + private started = false; + private inFlight = new Set(); + private seenEvents = new Map>(); + + async list(): Promise { + const rows = await db.select().from(automations).orderBy(desc(automations.updatedAt)); + return rows.map(mapAutomation); + } + + async get(id: string): Promise { + const rows = await db.select().from(automations).where(eq(automations.id, id)).limit(1); + return rows[0] ? mapAutomation(rows[0]) : null; + } + + async create(input: CreateAutomationInput): Promise { + const now = new Date().toISOString(); + const id = `auto_${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`; + const mode = input.mode ?? 'schedule'; + const nextRunAt = mode === 'schedule' ? computeNextRun(input.schedule) : null; + + await db.insert(automations).values({ + id, + name: input.name, + projectId: input.projectId, + projectName: input.projectName ?? '', + prompt: input.prompt, + agentId: input.agentId, + mode, + schedule: JSON.stringify(input.schedule), + triggerType: input.triggerType ?? null, + triggerConfig: input.triggerConfig ? JSON.stringify(input.triggerConfig) : null, + useWorktree: input.useWorktree === false ? 0 : 1, + status: 'active', + nextRunAt, + createdAt: now, + updatedAt: now, + }); + + const created = await this.get(id); + if (!created) throw new Error('Failed to load created automation'); + + if (created.mode === 'trigger') { + const events = await this.fetchRawEvents(created); + this.seenEvents.set(created.id, new Set(events.map((e) => e.id))); + } + + return created; + } + + async update(input: UpdateAutomationInput): Promise { + const existing = await this.get(input.id); + if (!existing) throw new Error('Automation not found'); + + const mode = input.mode ?? existing.mode; + const schedule = input.schedule ?? existing.schedule; + const nextRunAt = mode === 'schedule' ? computeNextRun(schedule) : null; + const triggerConfig = + input.triggerConfig === undefined ? existing.triggerConfig : input.triggerConfig; + const useWorktree = input.useWorktree === undefined ? existing.useWorktree : input.useWorktree; + + await db + .update(automations) + .set({ + name: input.name ?? existing.name, + projectId: input.projectId ?? existing.projectId, + projectName: input.projectName ?? existing.projectName, + prompt: input.prompt ?? existing.prompt, + agentId: input.agentId ?? existing.agentId, + mode, + schedule: JSON.stringify(schedule), + triggerType: + input.triggerType === undefined ? existing.triggerType : (input.triggerType ?? null), + triggerConfig: triggerConfig ? JSON.stringify(triggerConfig) : null, + status: input.status ?? existing.status, + useWorktree: useWorktree ? 1 : 0, + nextRunAt, + updatedAt: new Date().toISOString(), + }) + .where(eq(automations.id, input.id)); + + const updated = await this.get(input.id); + if (!updated) throw new Error('Failed to load updated automation'); + return updated; + } + + async delete(id: string): Promise { + await db.delete(automations).where(eq(automations.id, id)); + this.seenEvents.delete(id); + return true; + } + + async toggleStatus(id: string): Promise { + const existing = await this.get(id); + if (!existing) throw new Error('Automation not found'); + return this.update({ id, status: existing.status === 'active' ? 'paused' : 'active' }); + } + + async getRunLogs(automationId: string, limit = 100): Promise { + const rows = await db + .select() + .from(automationRunLogs) + .where(eq(automationRunLogs.automationId, automationId)) + .orderBy(desc(automationRunLogs.startedAt)) + .limit(Math.min(Math.max(limit, 1), 500)); + return rows.map(mapRun); + } + + async updateRunLog( + runId: string, + update: Partial> + ): Promise { + await db + .update(automationRunLogs) + .set({ + status: update.status, + error: update.error, + finishedAt: update.finishedAt, + taskId: update.taskId, + }) + .where(eq(automationRunLogs.id, runId)); + } + + async setLastRunResult( + automationId: string, + result: 'success' | 'failure', + error?: string + ): Promise { + await db + .update(automations) + .set({ + lastRunResult: result, + lastRunError: error ?? null, + updatedAt: new Date().toISOString(), + }) + .where(eq(automations.id, automationId)); + } + + async createManualRunLog(automationId: string): Promise { + const runLogId = `run_${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`; + const now = new Date().toISOString(); + await db.insert(automationRunLogs).values({ + id: runLogId, + automationId, + startedAt: now, + status: 'running', + taskId: null, + error: null, + finishedAt: null, + }); + return runLogId; + } + + async triggerNow(id: string): Promise { + const automation = await this.get(id); + if (!automation) throw new Error('Automation not found'); + if (automation.mode === 'trigger') { + throw new Error('Run now is only available for schedule automations'); + } + await this.runAutomation(automation); + } + + async reconcileMissedRuns(): Promise { + const nowIso = new Date().toISOString(); + await db + .update(automationRunLogs) + .set({ status: 'failure', error: 'Interrupted (app closed)', finishedAt: nowIso }) + .where(eq(automationRunLogs.status, 'running')); + } + + start(): void { + if (this.started) return; + this.started = true; + this.scheduleTimer = setInterval(() => { + void this.processScheduledAutomations().catch((error) => { + log.error('[Automations] Scheduled cycle failed:', error); + }); + }, 30_000); + this.triggerTimer = setInterval(() => { + void this.processTriggerAutomations().catch((error) => { + log.error('[Automations] Trigger cycle failed:', error); + }); + }, 10_000); + void this.processScheduledAutomations().catch((error) => { + log.error('[Automations] Initial scheduled cycle failed:', error); + }); + setTimeout(() => { + void this.processTriggerAutomations().catch((error) => { + log.error('[Automations] Initial trigger cycle failed:', error); + }); + }, 2_000); + } + + stop(): void { + this.started = false; + if (this.scheduleTimer) clearInterval(this.scheduleTimer); + if (this.triggerTimer) clearInterval(this.triggerTimer); + this.scheduleTimer = null; + this.triggerTimer = null; + } + + private async processScheduledAutomations(): Promise { + const now = new Date(); + const nowIso = now.toISOString(); + const due = await db + .select() + .from(automations) + .where( + and( + eq(automations.status, 'active'), + eq(automations.mode, 'schedule'), + lte(automations.nextRunAt, nowIso) + ) + ); + + for (const row of due) { + const automation = mapAutomation(row); + if (this.inFlight.has(automation.id)) continue; + void this.runAutomation(automation); + } + } + + private async processTriggerAutomations(): Promise { + const rows = await db + .select() + .from(automations) + .where(and(eq(automations.status, 'active'), eq(automations.mode, 'trigger'))); + + // Per-cycle dedup: multiple automations on the same source (e.g. several + // GitHub Issue Triages on the same repo) share a single upstream fetch. + const fetchCache = new Map>(); + const cacheKey = (automation: Automation) => + `${automation.triggerType ?? 'none'}:${automation.projectId}`; + + for (const row of rows) { + const automation = mapAutomation(row); + const key = cacheKey(automation); + let pending = fetchCache.get(key); + if (!pending) { + pending = this.fetchRawEvents(automation); + fetchCache.set(key, pending); + } + const events = await pending; + + // Don't wipe seen state on transient empty fetches (auth blip, rate limit, + // network error). An empty result set is treated as "no signal", not "no + // events exist". Without this guard, the next successful fetch would + // re-fire every existing event. + if (events.length === 0) { + if (!this.seenEvents.has(automation.id)) { + this.seenEvents.set(automation.id, new Set()); + } + continue; + } + + const seen = this.seenEvents.get(automation.id) ?? new Set(); + const isFirstObservation = !this.seenEvents.has(automation.id); + // Baseline timestamp: anything strictly newer than the automation's + // creation time is a candidate, even on the first poll after restart. + // This closes the gap where new events arriving between app start and + // the first poll would otherwise be silently absorbed into `seen`. + const baseline = Date.parse(automation.createdAt); + + const fresh = events.filter((event) => { + if (seen.has(event.id)) return false; + if (isFirstObservation) { + // On the very first observation for this automation in this process, + // only fire events whose createdAt is after the automation itself. + // If we have no timestamp, fall back to NOT firing to avoid spam. + if (!event.createdAt) return false; + const ts = Date.parse(event.createdAt); + if (!Number.isFinite(ts) || ts <= baseline) return false; + } + return this.matchesConfig(automation, event); + }); + + for (const event of fresh.slice(0, 3)) { + if (!this.inFlight.has(automation.id)) { + void this.runAutomation(automation, event); + } + } + // Replace the seen set with only the current window — bounded by the + // upstream `fetchRawEvents` page size (~30) so memory can't grow unbounded. + this.seenEvents.set(automation.id, new Set(events.map((e) => e.id))); + } + } + + private matchesConfig(automation: Automation, event: RawEvent): boolean { + const config = automation.triggerConfig; + if (!config) return true; + + if (config.labelFilter?.length) { + const labels = event.labels ?? []; + const match = config.labelFilter.some((wanted) => + labels.some((candidate) => candidate.toLowerCase() === wanted.toLowerCase()) + ); + if (!match) return false; + } + + if (config.assigneeFilter) { + if (!event.assignee) return false; + if (event.assignee.toLowerCase() !== config.assigneeFilter.toLowerCase()) return false; + } + + if (config.branchFilter) { + if (!event.branch) return false; + if (config.branchFilter.includes('*')) { + const escaped = config.branchFilter + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*'); + if (!new RegExp(`^${escaped}$`).test(event.branch)) return false; + } else if (event.branch !== config.branchFilter) { + return false; + } + } + + return true; + } + + private async resolveGitRemoteUrl(projectId: string): Promise { + try { + let provider = projectManager.getProject(projectId); + if (!provider) { + // Provider may not be initialized yet (the trigger poll fires 2s after + // startup, but project bootstrap can take longer). Open it on demand. + try { + await projectManager.openProjectById(projectId); + } catch (openErr) { + log.error( + `[Automations] Could not open project ${projectId} to resolve remote:`, + openErr + ); + return null; + } + provider = projectManager.getProject(projectId); + if (!provider) { + log.error( + `[Automations] Project ${projectId} provider unavailable after openProjectById` + ); + return null; + } + } + const state = await provider.getRemoteState(); + if (!state.selectedRemoteUrl) { + log.error( + `[Automations] Project ${projectId} has no selected remote URL (hasRemote=${state.hasRemote})` + ); + return null; + } + return state.selectedRemoteUrl; + } catch (error) { + log.error(`[Automations] Failed to resolve remote for project ${projectId}:`, error); + return null; + } + } + + private async fetchRawEvents(automation: Automation): Promise { + if (!automation.triggerType) return []; + + const project = await db.query.projects.findFirst({ + where: eq(projects.id, automation.projectId), + }); + if (!project) return []; + + switch (automation.triggerType) { + case 'github_issue': { + const remoteUrl = await this.resolveGitRemoteUrl(project.id); + return this.fetchGitHubIssues(remoteUrl); + } + case 'github_pr': { + const remoteUrl = await this.resolveGitRemoteUrl(project.id); + return this.fetchGitHubPullRequests(project.id, remoteUrl); + } + case 'linear_issue': + return this.fetchLinearIssues(); + case 'jira_issue': + return this.fetchJiraIssues(); + case 'gitlab_issue': + return this.fetchGitLabIssues(project.path); + case 'gitlab_mr': + return this.fetchGitLabMergeRequests(project.path); + case 'forgejo_issue': + return this.fetchForgejoIssues(project.path); + case 'plain_thread': + return this.fetchPlainThreads(); + case 'sentry_issue': + return []; + default: + return []; + } + } + + private async fetchGitHubIssues(remote: string | null): Promise { + const nameWithOwner = parseNameWithOwner(remote); + if (!nameWithOwner) return []; + const issues = await issueService.listIssues(nameWithOwner, 30); + return issues.map((issue) => ({ + id: `gh-issue-${issue.number}`, + title: issue.title, + url: issue.url, + labels: issue.labels.map((l) => l.name), + assignee: issue.assignees[0]?.login, + createdAt: issue.createdAt ?? undefined, + })); + } + + private async fetchGitHubPullRequests( + projectId: string, + remote: string | null + ): Promise { + const nameWithOwner = parseNameWithOwner(remote); + if (!nameWithOwner) return []; + const { prs } = await prService.listPullRequests(projectId, nameWithOwner); + return prs.slice(0, 30).map((pr) => ({ + id: `gh-pr-${pr.id}`, + title: pr.title, + url: pr.url, + labels: pr.labels.map((l) => l.name), + assignee: pr.assignees[0]?.userName, + branch: pr.metadata.headRefName, + })); + } + + private async fetchLinearIssues(): Promise { + const status = await linearService.checkConnection(); + if (!status.connected) return []; + const issues = await linearService.initialFetch(30); + return issues.map((issue) => ({ + id: `linear-${issue.id}`, + title: issue.title, + url: issue.url, + assignee: issue.assignee?.name ?? issue.assignee?.displayName ?? undefined, + })); + } + + private async fetchJiraIssues(): Promise { + const jira = new JiraService(); + const status = await jira.checkConnection(); + if (!status.connected) return []; + const issues = await jira.initialFetch(30); + return issues.map((issue) => ({ + id: `jira-${issue.id}`, + title: issue.summary, + url: issue.url, + assignee: issue.assignee?.name, + })); + } + + private async fetchGitLabIssues(projectPath: string): Promise { + const status = await gitlabService.checkConnection(); + if (!status.connected) return []; + const issues = await gitlabService.initialFetch(projectPath, 30); + return issues.map((issue) => ({ + id: `gitlab-issue-${issue.id}`, + title: issue.title, + url: issue.webUrl ?? undefined, + labels: issue.labels, + assignee: issue.assignee?.username, + })); + } + + private async fetchGitLabMergeRequests(projectPath: string): Promise { + const status = await gitlabService.checkConnection(); + if (!status.connected) return []; + const mrs = await gitlabService.initialFetchMergeRequests(projectPath, 30); + return mrs.map((mr) => ({ + id: `gitlab-mr-${mr.id}`, + title: mr.title, + url: mr.webUrl ?? undefined, + labels: mr.labels, + branch: mr.sourceBranch ?? undefined, + assignee: mr.assignee?.username, + })); + } + + private async fetchForgejoIssues(projectPath: string): Promise { + const status = await forgejoService.checkConnection(); + if (!status.connected) return []; + const issues = await forgejoService.initialFetch(projectPath, 30); + return issues.map((issue) => ({ + id: `forgejo-${issue.id}`, + title: issue.title, + url: issue.htmlUrl ?? undefined, + labels: issue.labels, + assignee: issue.assignee?.username, + })); + } + + private async fetchPlainThreads(): Promise { + const status = await plainService.checkConnection(); + if (!status.connected) return []; + const threads = await plainService.initialFetch(30); + return threads.map((thread) => ({ + id: `plain-${thread.id}`, + title: thread.title, + url: thread.url ?? undefined, + })); + } + + async runAutomation(automation: Automation, triggerEvent?: RawEvent): Promise { + if (this.inFlight.has(automation.id)) return; + this.inFlight.add(automation.id); + + const runId = await this.createManualRunLog(automation.id); + const nowIso = new Date().toISOString(); + + try { + const project = await db.query.projects.findFirst({ + where: eq(projects.id, automation.projectId), + }); + if (!project) throw new Error(`Project not found: ${automation.projectId}`); + if (!isValidProviderId(automation.agentId)) + throw new Error(`Invalid agent: ${automation.agentId}`); + + const baseBranch = project.baseRef || 'main'; + const branchBase = `${slug(automation.name)}-${new Date().toISOString().slice(0, 10)}`; + + const taskId = crypto.randomUUID(); + const taskResult = await createTask({ + id: taskId, + projectId: automation.projectId, + name: triggerEvent ? `${automation.name}: ${triggerEvent.title}` : automation.name, + sourceBranch: { branch: baseBranch, remote: 'origin' }, + strategy: automation.useWorktree + ? { kind: 'new-branch', taskBranch: branchBase } + : { kind: 'no-worktree' }, + initialConversation: { + id: crypto.randomUUID(), + projectId: automation.projectId, + taskId, + provider: automation.agentId as AgentProviderId, + title: automation.name, + initialPrompt: triggerEvent + ? `${automation.prompt}\n\nTrigger context:\n- ${triggerEvent.title}\n${triggerEvent.url ?? ''}` + : automation.prompt, + }, + }); + + if (!taskResult.success) { + throw new Error(taskResult.error.type); + } + + await this.updateRunLog(runId, { + status: 'success', + finishedAt: new Date().toISOString(), + taskId: taskResult.data.id, + }); + + events.emit(taskCreatedExternallyChannel, { + projectId: automation.projectId, + taskId: taskResult.data.id, + }); + + await db + .update(automations) + .set({ + runCount: automation.runCount + 1, + lastRunAt: nowIso, + nextRunAt: automation.mode === 'schedule' ? computeNextRun(automation.schedule) : null, + lastRunResult: 'success', + lastRunError: null, + updatedAt: nowIso, + }) + .where(eq(automations.id, automation.id)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.error(`[Automations] Run failed (${automation.id}):`, error); + await this.updateRunLog(runId, { + status: 'failure', + finishedAt: new Date().toISOString(), + error: message, + }); + await db + .update(automations) + .set({ + nextRunAt: automation.mode === 'schedule' ? computeNextRun(automation.schedule) : null, + lastRunResult: 'failure', + lastRunError: message, + updatedAt: new Date().toISOString(), + }) + .where(eq(automations.id, automation.id)); + } finally { + this.inFlight.delete(automation.id); + } + } +} + +export const automationsService = new AutomationsService(); diff --git a/src/main/core/automations/controller.ts b/src/main/core/automations/controller.ts new file mode 100644 index 000000000..3a361c7a6 --- /dev/null +++ b/src/main/core/automations/controller.ts @@ -0,0 +1,113 @@ +import type { CreateAutomationInput, UpdateAutomationInput } from '@shared/automations/types'; +import { createRPCController } from '@shared/ipc/rpc'; +import { db } from '@main/db/client'; +import { log } from '@main/lib/logger'; +import { automationsService } from './automations-service'; + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function resolveProjectName(projectId: string): Promise { + const project = await db.query.projects.findFirst({ + where: (p, { eq }) => eq(p.id, projectId), + }); + return project?.name ?? null; +} + +export async function startAutomationsRuntime(): Promise { + try { + await automationsService.reconcileMissedRuns(); + automationsService.start(); + } catch (error) { + log.error('Failed to start automations runtime:', error); + } +} + +export function stopAutomationsRuntime(): void { + automationsService.stop(); +} + +export const automationsController = createRPCController({ + list: async () => { + try { + return { success: true, data: await automationsService.list() }; + } catch (error) { + return { success: false, error: formatError(error) }; + } + }, + + get: async (id: string) => { + try { + return { success: true, data: await automationsService.get(id) }; + } catch (error) { + return { success: false, error: formatError(error) }; + } + }, + + create: async (input: CreateAutomationInput) => { + try { + const projectName = await resolveProjectName(input.projectId); + if (projectName === null) + return { success: false, error: `Unknown projectId: ${input.projectId}` }; + + const created = await automationsService.create({ ...input, projectName }); + return { success: true, data: created }; + } catch (error) { + return { success: false, error: formatError(error) }; + } + }, + + update: async (input: UpdateAutomationInput) => { + try { + let projectName = input.projectName; + if (input.projectId) { + const resolved = await resolveProjectName(input.projectId); + if (resolved === null) + return { success: false, error: `Unknown projectId: ${input.projectId}` }; + projectName = resolved; + } + + const updated = await automationsService.update({ ...input, projectName }); + return { success: true, data: updated }; + } catch (error) { + return { success: false, error: formatError(error) }; + } + }, + + delete: async (id: string) => { + try { + return { success: true, data: await automationsService.delete(id) }; + } catch (error) { + return { success: false, error: formatError(error) }; + } + }, + + toggle: async (id: string) => { + try { + return { success: true, data: await automationsService.toggleStatus(id) }; + } catch (error) { + return { success: false, error: formatError(error) }; + } + }, + + runLogs: async (automationId: string, limit?: number) => { + try { + return { + success: true, + data: await automationsService.getRunLogs(automationId, limit ?? 100), + }; + } catch (error) { + return { success: false, error: formatError(error) }; + } + }, + + triggerNow: async (id: string) => { + try { + await automationsService.triggerNow(id); + return { success: true }; + } catch (error) { + return { success: false, error: formatError(error) }; + } + }, +}); diff --git a/src/main/core/conversations/impl/local-conversation.ts b/src/main/core/conversations/impl/local-conversation.ts index 7553b5fc6..8f1f8554b 100644 --- a/src/main/core/conversations/impl/local-conversation.ts +++ b/src/main/core/conversations/impl/local-conversation.ts @@ -105,6 +105,15 @@ export class LocalConversationProvider implements ConversationProvider { const spawnParams = resolveSpawnParams('agent', cfg); + log.info('LocalConversationProvider: spawning agent', { + conversationId: conversation.id, + providerId: conversation.providerId, + isResuming, + hasInitialPrompt: !!initialPrompt, + initialPromptPreview: initialPrompt ? initialPrompt.slice(0, 120) : null, + spawnArgs: spawnParams.args, + }); + const ptyId = makePtyId(conversation.providerId, conversation.id); const port = agentHookService.getPort(); const token = agentHookService.getToken(); diff --git a/src/main/core/github/services/issue-service.ts b/src/main/core/github/services/issue-service.ts index 250770cfb..a69e4ffb2 100644 --- a/src/main/core/github/services/issue-service.ts +++ b/src/main/core/github/services/issue-service.ts @@ -1,4 +1,5 @@ import type { Octokit } from '@octokit/rest'; +import { log } from '@main/lib/logger'; import { getOctokit } from './octokit-provider'; import { splitRepo } from './utils'; @@ -70,7 +71,8 @@ export class GitHubIssueServiceImpl implements GitHubIssueService { return data .filter((issue) => !issue.pull_request) .map((item) => this.mapIssue(item as unknown as RestIssue)); - } catch { + } catch (error) { + log.error(`[GitHub] listIssues failed for ${nameWithOwner}:`, error); return []; } } diff --git a/src/main/core/gitlab/gitlab-service.ts b/src/main/core/gitlab/gitlab-service.ts index 43b2e39e9..929d50f54 100644 --- a/src/main/core/gitlab/gitlab-service.ts +++ b/src/main/core/gitlab/gitlab-service.ts @@ -35,6 +35,19 @@ export interface GitLabIssueSummary { updatedAt: string | null; } +export interface GitLabMergeRequestSummary { + id: number; + iid: number; + title: string; + description: string | null; + webUrl: string | null; + state: string | null; + sourceBranch: string | null; + labels: string[]; + assignee: { name: string; username: string } | null; + updatedAt: string | null; +} + const gitlabKV = new KV('gitlab'); export class GitlabService { @@ -180,6 +193,34 @@ export class GitlabService { } } + async initialFetchMergeRequests( + projectPath: string, + limit = 50 + ): Promise { + const path = projectPath.trim(); + if (!path) { + throw new Error('Project path is required.'); + } + + const perPage = Number.isFinite(limit) ? Math.max(1, Math.min(limit, 100)) : 50; + const { client, projectId } = await this.resolveProject(path); + + try { + const mergeRequests = (await client.MergeRequests.all({ + projectId, + state: 'opened', + orderBy: 'updated_at', + sort: 'desc', + perPage, + maxPages: 1, + })) as unknown[]; + + return this.normalizeMergeRequests(mergeRequests); + } catch (error) { + throw new Error(this.toErrorMessage(error, 'Failed to fetch GitLab merge requests.')); + } + } + private async requireAuth(): Promise<{ instanceUrl: string; client: Gitlab }> { const connection = await this.readConnection(); if (!connection) { @@ -300,6 +341,13 @@ export class GitlabService { .filter((issue): issue is GitLabIssueSummary => issue !== null); } + private normalizeMergeRequests(rawMergeRequests: unknown[]): GitLabMergeRequestSummary[] { + const mergeRequests = Array.isArray(rawMergeRequests) ? rawMergeRequests : []; + return mergeRequests + .map((item) => this.mapMergeRequest(item)) + .filter((mergeRequest): mergeRequest is GitLabMergeRequestSummary => mergeRequest !== null); + } + private mapIssue(raw: unknown, projectName: string | null): GitLabIssueSummary | null { const item = this.asRecord(raw); if (!item) return null; @@ -353,6 +401,52 @@ export class GitlabService { }; } + private mapMergeRequest(raw: unknown): GitLabMergeRequestSummary | null { + const item = this.asRecord(raw); + if (!item) return null; + + const id = this.readNumber(item.id); + const iid = this.readNumber(item.iid); + if (id === null || iid === null) return null; + + const assigneeRecord = + this.asRecord(item.assignee) ?? + (Array.isArray(item.assignees) ? this.asRecord(item.assignees[0]) : null); + const assigneeName = + this.readString(assigneeRecord?.name) ?? this.readString(assigneeRecord?.username); + const assigneeUsername = + this.readString(assigneeRecord?.username) ?? this.readString(assigneeRecord?.name); + + const labels = Array.isArray(item.labels) + ? item.labels + .map((label) => { + if (typeof label === 'string') return label; + const labelObj = this.asRecord(label); + return this.readString(labelObj?.name); + }) + .filter((label): label is string => Boolean(label)) + : []; + + return { + id, + iid, + title: this.readString(item.title) ?? '', + description: this.readString(item.description), + webUrl: this.readString(item.web_url) ?? this.readString(item.webUrl), + state: this.readString(item.state), + sourceBranch: this.readString(item.source_branch) ?? this.readString(item.sourceBranch), + labels, + assignee: + assigneeName || assigneeUsername + ? { + name: assigneeName ?? assigneeUsername ?? '', + username: assigneeUsername ?? assigneeName ?? '', + } + : null, + updatedAt: this.readString(item.updated_at) ?? this.readString(item.updatedAt), + }; + } + private getClient(instanceUrl: string, token: string): Gitlab { const key = `${instanceUrl}|${token}`; if (!this.client || this.clientKey !== key) { diff --git a/src/main/core/integrations/controller.ts b/src/main/core/integrations/controller.ts new file mode 100644 index 000000000..757d7e244 --- /dev/null +++ b/src/main/core/integrations/controller.ts @@ -0,0 +1,31 @@ +import type { IntegrationStatusMap } from '@shared/integrations/types'; +import { createRPCController } from '@shared/ipc/rpc'; +import { forgejoService } from '@main/core/forgejo/forgejo-service'; +import { githubAuthService } from '@main/core/github/services/github-auth-service'; +import { gitlabService } from '@main/core/gitlab/gitlab-service'; +import { jiraService } from '@main/core/jira/JiraService'; +import { linearService } from '@main/core/linear/LinearService'; +import { plainService } from '@main/core/plain/plain-service'; + +export const integrationsController = createRPCController({ + statusMap: async (): Promise => { + const [github, linear, jira, gitlab, plain, forgejo] = await Promise.all([ + githubAuthService.isAuthenticated(), + linearService.checkConnection(), + jiraService.checkConnection(), + gitlabService.checkConnection(), + plainService.checkConnection(), + forgejoService.checkConnection(), + ]); + + return { + github, + linear: linear.connected, + jira: jira.connected, + gitlab: gitlab.connected, + plain: plain.connected, + forgejo: forgejo.connected, + sentry: false, + }; + }, +}); diff --git a/src/main/core/pty/spawn-utils.ts b/src/main/core/pty/spawn-utils.ts index a1823fcbd..6c5a39ca5 100644 --- a/src/main/core/pty/spawn-utils.ts +++ b/src/main/core/pty/spawn-utils.ts @@ -10,6 +10,23 @@ export interface SpawnParams { cwd: string; } +/** + * POSIX shell single-quote escape. Wraps the value in single quotes and + * escapes any embedded single quotes via the standard `'\''` trick. Safe to + * pass as a token to `sh -c`. + */ +function shellQuote(value: string): string { + if (value === '') return "''"; + // If the value is "safe" (alphanumerics + a few harmless chars) we can leave + // it bare; this keeps existing test fixtures and command lines readable. + if (/^[A-Za-z0-9_\-./:=@%+,]+$/.test(value)) return value; + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function joinShellCommand(parts: string[]): string { + return parts.map(shellQuote).join(' '); +} + /** * Derive the executable, arguments, and working directory from a session config. * Applies shellSetup and tmux wrapping where relevant. @@ -20,7 +37,7 @@ export function resolveSpawnParams(type: SessionType, config: SessionConfig): Sp switch (type) { case 'agent': { const cfg = config as AgentSessionConfig; - const baseCmd = [cfg.command, ...cfg.args].join(' '); + const baseCmd = joinShellCommand([cfg.command, ...cfg.args]); const fullCmd = cfg.shellSetup ? `${cfg.shellSetup} && ${baseCmd}` : baseCmd; if (cfg.tmuxSessionName) { @@ -37,7 +54,7 @@ export function resolveSpawnParams(type: SessionType, config: SessionConfig): Sp case 'general': { const cfg = config as GeneralSessionConfig; const baseCmd = cfg.command - ? [cfg.command, ...(cfg.args ?? [])].join(' ') + ? joinShellCommand([cfg.command, ...(cfg.args ?? [])]) : `exec ${shell} -il`; const fullCmd = cfg.shellSetup ? `${cfg.shellSetup} && ${baseCmd}` : baseCmd; @@ -98,7 +115,8 @@ export function resolveSshCommand( const { command, args, cwd } = resolveSpawnParams(type, config); const shell = process.env.SHELL ?? '/bin/sh'; - const innerCmd = command === shell && args[0] === '-c' ? args[1] : [command, ...args].join(' '); + const innerCmd = + command === shell && args[0] === '-c' ? args[1] : joinShellCommand([command, ...args]); const envPrefix = envVars ? buildSshEnvPrefix(envVars) : ''; return `cd ${JSON.stringify(cwd)} && ${envPrefix}${innerCmd}`; diff --git a/src/main/core/telemetry/controller.ts b/src/main/core/telemetry/controller.ts new file mode 100644 index 000000000..dcfee52d9 --- /dev/null +++ b/src/main/core/telemetry/controller.ts @@ -0,0 +1,35 @@ +import { createRPCController } from '@shared/ipc/rpc'; +import { + capture as captureTelemetryEvent, + getTelemetryClientConfig, + getTelemetryStatus, + setTelemetryEnabledViaUser, +} from '@main/lib/telemetry'; + +export const telemetryController = createRPCController({ + capture: async ({ + event, + properties, + }: { + event: string; + properties?: Record; + }) => { + captureTelemetryEvent(event as Parameters[0], properties); + }, + + getStatus: async () => { + return { + status: getTelemetryStatus(), + clientConfig: getTelemetryClientConfig(), + }; + }, + + setEnabled: async (enabled: boolean) => { + setTelemetryEnabledViaUser(enabled); + + return { + status: getTelemetryStatus(), + clientConfig: getTelemetryClientConfig(), + }; + }, +}); diff --git a/src/main/db/initialize.ts b/src/main/db/initialize.ts index a713df77a..3783e7df9 100644 --- a/src/main/db/initialize.ts +++ b/src/main/db/initialize.ts @@ -13,6 +13,38 @@ const sqlFiles = import.meta.glob('@root/drizzle/*.sql', { type JournalEntry = { idx: number; when: number; tag: string; breakpoints: boolean }; +function ensureAutomationColumns(connection: BetterSqlite3.Database): void { + const tableExists = connection + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='automations'") + .get() as { name: string } | undefined; + + if (!tableExists) return; + + const columns = connection.prepare('PRAGMA table_info(automations)').all() as Array<{ + name: string; + }>; + const has = (name: string) => columns.some((c) => c.name === name); + + if (!has('use_worktree')) { + connection.exec('ALTER TABLE automations ADD COLUMN use_worktree integer DEFAULT 1 NOT NULL'); + } + if (!has('mode')) { + connection.exec("ALTER TABLE automations ADD COLUMN mode text DEFAULT 'schedule' NOT NULL"); + } + if (!has('trigger_type')) { + connection.exec('ALTER TABLE automations ADD COLUMN trigger_type text'); + } + if (!has('trigger_config')) { + connection.exec('ALTER TABLE automations ADD COLUMN trigger_config text'); + } + if (!has('last_run_result')) { + connection.exec('ALTER TABLE automations ADD COLUMN last_run_result text'); + } + if (!has('last_run_error')) { + connection.exec('ALTER TABLE automations ADD COLUMN last_run_error text'); + } +} + function runBundledMigrations(connection: BetterSqlite3.Database): void { connection.exec(` CREATE TABLE IF NOT EXISTS __drizzle_migrations ( @@ -60,5 +92,6 @@ function runBundledMigrations(connection: BetterSqlite3.Database): void { */ export async function initializeDatabase(): Promise { runBundledMigrations(sqlite); + ensureAutomationColumns(sqlite); return sqlite; } diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index 064227f09..61453bc46 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -323,6 +323,64 @@ export const kv = sqliteTable( }) ); +export const automations = sqliteTable( + 'automations', + { + id: text('id').primaryKey(), + projectId: text('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + projectName: text('project_name').notNull().default(''), + name: text('name').notNull(), + prompt: text('prompt').notNull(), + agentId: text('agent_id').notNull(), + mode: text('mode').notNull().default('schedule'), + schedule: text('schedule').notNull(), + triggerType: text('trigger_type'), + triggerConfig: text('trigger_config'), + useWorktree: integer('use_worktree').notNull().default(1), + status: text('status').notNull().default('active'), + lastRunAt: text('last_run_at'), + nextRunAt: text('next_run_at'), + runCount: integer('run_count').notNull().default(0), + lastRunResult: text('last_run_result'), + lastRunError: text('last_run_error'), + createdAt: text('created_at') + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at') + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => ({ + projectIdIdx: index('idx_automations_project_id').on(table.projectId), + statusNextRunIdx: index('idx_automations_status_next_run').on(table.status, table.nextRunAt), + updatedAtIdx: index('idx_automations_updated_at').on(table.updatedAt), + }) +); + +export const automationRunLogs = sqliteTable( + 'automation_run_logs', + { + id: text('id').primaryKey(), + automationId: text('automation_id') + .notNull() + .references(() => automations.id, { onDelete: 'cascade' }), + startedAt: text('started_at').notNull(), + finishedAt: text('finished_at'), + status: text('status').notNull(), + error: text('error'), + taskId: text('task_id').references(() => tasks.id, { onDelete: 'set null' }), + }, + (table) => ({ + automationStartedIdx: index('idx_automation_run_logs_automation_started').on( + table.automationId, + table.startedAt + ), + statusIdx: index('idx_automation_run_logs_status').on(table.status), + }) +); + export type KvRow = typeof kv.$inferSelect; export type KvInsert = typeof kv.$inferInsert; @@ -345,6 +403,7 @@ export const tasksRelations = relations(tasks, ({ one, many }) => ({ }), conversations: many(conversations), lineComments: many(lineComments), + automationRunLogs: many(automationRunLogs), })); export const conversationsRelations = relations(conversations, ({ one, many }) => ({ @@ -369,6 +428,25 @@ export const lineCommentsRelations = relations(lineComments, ({ one }) => ({ }), })); +export const automationsRelations = relations(automations, ({ one, many }) => ({ + project: one(projects, { + fields: [automations.projectId], + references: [projects.id], + }), + runLogs: many(automationRunLogs), +})); + +export const automationRunLogsRelations = relations(automationRunLogs, ({ one }) => ({ + automation: one(automations, { + fields: [automationRunLogs.automationId], + references: [automations.id], + }), + task: one(tasks, { + fields: [automationRunLogs.taskId], + references: [tasks.id], + }), +})); + export type SshConnectionRow = typeof sshConnections.$inferSelect; export type SshConnectionInsert = typeof sshConnections.$inferInsert; export type ProjectRow = typeof projects.$inferSelect; @@ -380,3 +458,7 @@ export type LineCommentRow = typeof lineComments.$inferSelect; export type LineCommentInsert = typeof lineComments.$inferInsert; export type EditorBufferRow = typeof editorBuffers.$inferSelect; export type EditorBufferInsert = typeof editorBuffers.$inferInsert; +export type AutomationRow = typeof automations.$inferSelect; +export type AutomationInsert = typeof automations.$inferInsert; +export type AutomationRunLogRow = typeof automationRunLogs.$inferSelect; +export type AutomationRunLogInsert = typeof automationRunLogs.$inferInsert; diff --git a/src/main/index.ts b/src/main/index.ts index 31f21c57d..08b9594b9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -10,6 +10,7 @@ import { providerTokenRegistry } from './core/account/provider-token-registry'; import { emdashAccountService } from './core/account/services/emdash-account-service'; import { agentHookService } from './core/agent-hooks/agent-hook-service'; import { appService } from './core/app/service'; +import { startAutomationsRuntime, stopAutomationsRuntime } from './core/automations/controller'; import { localDependencyManager } from './core/dependencies/dependency-manager'; import { editorBufferService } from './core/editor/editor-buffer-service'; import { githubAuthService } from './core/github/services/github-auth-service'; @@ -106,6 +107,8 @@ app.whenReady().then(async () => { registerRPCRouter(rpcRouter, ipcMain); + await startAutomationsRuntime(); + localDependencyManager.probeAll().catch((e) => { log.error('Failed to probe dependencies:', e); }); @@ -130,6 +133,7 @@ app.on('before-quit', () => { agentHookService.stop(); updateService.shutdown(); + stopAutomationsRuntime(); projectManager.shutdown().catch((e) => { log.error('Failed to shutdown project manager:', e); }); diff --git a/src/main/lib/telemetry.ts b/src/main/lib/telemetry.ts index a286d6e49..3c2a14239 100644 --- a/src/main/lib/telemetry.ts +++ b/src/main/lib/telemetry.ts @@ -426,6 +426,17 @@ export function getTelemetryStatus() { }; } +export function getTelemetryClientConfig() { + if (!apiKey || !host) { + return null; + } + + return { + posthogKey: apiKey, + posthogHost: host, + }; +} + export function setTelemetryEnabledViaUser(enabledFlag: boolean): void { userOptOut = !enabledFlag; telemetryKV.set('enabled', String(enabledFlag)); diff --git a/src/main/rpc.ts b/src/main/rpc.ts index 3a3cadc7d..637b765f8 100644 --- a/src/main/rpc.ts +++ b/src/main/rpc.ts @@ -1,6 +1,7 @@ import { createRPCRouter } from '../shared/ipc/rpc'; import { accountController } from './core/account/controller'; import { appController } from './core/app/controller'; +import { automationsController } from './core/automations/controller'; import { conversationController } from './core/conversations/controller'; import { dependenciesController } from './core/dependencies/controller'; import { editorBufferController } from './core/editor/controller'; @@ -9,6 +10,7 @@ import { filesController } from './core/fs/controller'; import { gitController } from './core/git/controller'; import { githubController } from './core/github/controller'; import { gitlabController } from './core/gitlab/controller'; +import { integrationsController } from './core/integrations/controller'; import { jiraController } from './core/jira/controller'; import { lineCommentsController } from './core/line-comments'; import { linearController } from './core/linear/controller'; @@ -23,6 +25,7 @@ import { providerSettingsController } from './core/settings/provider-settings-co import { skillsController } from './core/skills/controller'; import { sshController } from './core/ssh/controller'; import { taskController } from './core/tasks/controller'; +import { telemetryController } from './core/telemetry/controller'; import { terminalsController } from './core/terminals/controller'; import { updateController } from './core/updates/controller'; import { viewStateController } from './core/view-state/controller'; @@ -30,7 +33,9 @@ import { viewStateController } from './core/view-state/controller'; export const rpcRouter = createRPCRouter({ account: accountController, app: appController, + automations: automationsController, appSettings: appSettingsController, + integrations: integrationsController, providerSettings: providerSettingsController, repository: repositoryController, fs: filesController, @@ -47,6 +52,7 @@ export const rpcRouter = createRPCRouter({ ssh: sshController, projects: projectController, tasks: taskController, + telemetry: telemetryController, conversations: conversationController, terminals: terminalsController, git: gitController, diff --git a/src/preload/index.ts b/src/preload/index.ts index 9fb51bc28..aebbd9a1d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -11,4 +11,24 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on(channel, wrapped); return () => ipcRenderer.removeListener(channel, wrapped); }, + + // Automations compatibility helpers + automationsList: () => ipcRenderer.invoke('automations.list'), + automationsGet: (args: { id: string }) => ipcRenderer.invoke('automations.get', args.id), + automationsCreate: (args: unknown) => ipcRenderer.invoke('automations.create', args), + automationsUpdate: (args: unknown) => ipcRenderer.invoke('automations.update', args), + automationsDelete: (args: { id: string }) => ipcRenderer.invoke('automations.delete', args.id), + automationsToggle: (args: { id: string }) => ipcRenderer.invoke('automations.toggle', args.id), + automationsRunLogs: (args: { automationId: string; limit?: number }) => + ipcRenderer.invoke('automations.runLogs', args.automationId, args.limit), + automationsTriggerNow: (args: { id: string }) => + ipcRenderer.invoke('automations.triggerNow', args.id), + automationsCompleteRun: (_args: unknown) => Promise.resolve({ success: true }), + automationsDrainTriggers: () => Promise.resolve({ success: true, data: [] }), + onAutomationTriggerAvailable: (_cb: () => void) => () => {}, + + worktreeCreate: (_projectId: string, _taskId: string, _branch?: string) => + Promise.resolve({ success: true }), + worktreeRemove: (_projectId: string, _taskId: string) => Promise.resolve({ success: true }), + onPtyExit: (_id: string, _cb: (payload: { exitCode: number }) => void) => () => {}, }); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d589087e6..14723c3eb 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,4 +1,5 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useEffect } from 'react'; import ErrorBoundary from './components/error-boundary'; import { RightSidebarProvider } from './components/ui/right-sidebar'; import { TooltipProvider } from './components/ui/tooltip'; @@ -10,24 +11,36 @@ import { TerminalPoolProvider } from './core/pty/pty-pool-provider'; import { SshConnectionProvider } from './core/ssh/ssh-connection-provider'; import { WorkspaceLayoutContextProvider } from './core/view/layout-provider'; import { WorkspaceViewProvider } from './core/view/provider'; +import { useAccountSession } from './hooks/useAccount'; import { useLocalStorage } from './hooks/useLocalStorage'; -import { WelcomeScreen } from './views/welcome'; -import { Workspace } from './views/workspace'; +import { syncPosthogIdentity } from './lib/posthog-flags'; +import { WelcomeScreen } from './views/Welcome'; +import { Workspace } from './views/Workspace'; export const FIRST_LAUNCH_KEY = 'emdash:first-launch:v1'; const queryClient = new QueryClient(); -export function App() { +function usePosthogIdentitySync() { + const { data: session } = useAccountSession(); + useEffect(() => { + if (session) { + syncPosthogIdentity(session.user, session.isSignedIn); + } + }, [session]); +} + +function AppShell() { const [isFirstLaunch, setIsFirstLaunch] = useLocalStorage(FIRST_LAUNCH_KEY, true); + usePosthogIdentitySync(); - const renderContent = () => { - if (isFirstLaunch) { - return setIsFirstLaunch(false)} />; - } - return ; - }; + if (isFirstLaunch) { + return setIsFirstLaunch(false)} />; + } + return ; +} +export function App() { return ( @@ -40,7 +53,9 @@ export function App() { - {renderContent()} + + + diff --git a/src/renderer/components/AgentLogo.tsx b/src/renderer/components/AgentLogo.tsx new file mode 100644 index 000000000..11114bd4b --- /dev/null +++ b/src/renderer/components/AgentLogo.tsx @@ -0,0 +1,46 @@ +import { agentConfig } from '@renderer/lib/agentConfig'; +import { cn } from '@renderer/lib/utils'; +import type { Agent } from '@renderer/types'; + +type LegacyProps = { + logo?: string; + alt?: string; + isSvg?: boolean; + invertInDark?: boolean; +}; + +export default function AgentLogo({ + provider, + className, + logo, + alt, + isSvg, +}: { + provider?: Agent; + className?: string; +} & LegacyProps) { + const info = provider ? agentConfig[provider] : undefined; + const effectiveLogo = logo ?? info?.logo; + const effectiveAlt = alt ?? info?.alt ?? 'agent'; + const effectiveIsSvg = isSvg ?? info?.isSvg; + if (!effectiveLogo) { + return
; + } + + if (effectiveIsSvg) { + return ( + + ); + } + + return ( + {effectiveAlt} + ); +} diff --git a/src/renderer/components/TaskStatusIndicator.tsx b/src/renderer/components/TaskStatusIndicator.tsx new file mode 100644 index 000000000..0cc963e82 --- /dev/null +++ b/src/renderer/components/TaskStatusIndicator.tsx @@ -0,0 +1,13 @@ +import type { TaskLifecycleStatus } from '@shared/tasks'; + +export function TaskStatusIndicator({ status }: { status: TaskLifecycleStatus }) { + const color = + status === 'done' + ? 'bg-emerald-500' + : status === 'in_progress' + ? 'bg-blue-500' + : status === 'review' + ? 'bg-amber-500' + : 'bg-zinc-500'; + return ; +} diff --git a/src/renderer/components/agent-selector.tsx b/src/renderer/components/agent-selector.tsx index bd23e1bc6..221ac8544 100644 --- a/src/renderer/components/agent-selector.tsx +++ b/src/renderer/components/agent-selector.tsx @@ -108,7 +108,7 @@ export const AgentSelector: React.FC = observer( Select agent )} - + {(group: AgentGroup) => ( diff --git a/src/renderer/components/automations/AutomationInlineCreate.tsx b/src/renderer/components/automations/AutomationInlineCreate.tsx new file mode 100644 index 000000000..a23b1cbad --- /dev/null +++ b/src/renderer/components/automations/AutomationInlineCreate.tsx @@ -0,0 +1,596 @@ +import { + Check, + Clock, + FolderGit2, + FolderOpen, + GitBranch, + Github, + MoreHorizontal, + Zap, +} from 'lucide-react'; +import { AnimatePresence, motion } from 'motion/react'; +import React, { useEffect, useRef, useState } from 'react'; +import type { AgentProviderId } from '@shared/agent-provider-registry'; +import type { + Automation, + AutomationMode, + CreateAutomationInput, + ScheduleType, + TriggerType, + UpdateAutomationInput, +} from '@shared/automations/types'; +import { INTEGRATION_LABELS } from '@shared/integrations/types'; +import { useIntegrationStatusMap } from '../../hooks/useIntegrationStatusMap'; +import type { Project } from '../../types/app'; +import { AgentSelector } from '../agent-selector'; +import { Button } from '../ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../ui/dropdown-menu'; +import { Input } from '../ui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; +import { Textarea } from '../ui/textarea'; +import { + buildSchedule, + DAYS_OF_MONTH, + DAYS_OF_WEEK, + formatScheduleLabel, + formatTriggerLabel, + SCHEDULE_TYPES, + TRIGGER_INTEGRATION_MAP, + TRIGGER_TYPES, +} from './utils'; + +interface AutomationInlineCreateProps { + projects: Project[]; + prefill?: { + name: string; + prompt: string; + mode?: AutomationMode; + triggerType?: TriggerType; + } | null; + editingAutomation?: Automation | null; + onSave: (input: CreateAutomationInput) => Promise; + onUpdate?: (input: UpdateAutomationInput) => Promise; + onCancel: () => void; +} + +const AutomationInlineCreate: React.FC = ({ + projects, + prefill, + editingAutomation, + onSave, + onUpdate, + onCancel, +}) => { + const isEditing = !!editingAutomation; + const { statuses: integrationStatuses } = useIntegrationStatusMap(); + + const [name, setName] = useState(editingAutomation?.name ?? prefill?.name ?? ''); + const [projectId, setProjectId] = useState(editingAutomation?.projectId ?? projects[0]?.id ?? ''); + const [prompt, setPrompt] = useState(editingAutomation?.prompt ?? prefill?.prompt ?? ''); + const [agentId, setAgentId] = useState(editingAutomation?.agentId ?? 'claude'); + const [mode, setMode] = useState( + editingAutomation?.mode ?? prefill?.mode ?? 'schedule' + ); + const [triggerType, setTriggerType] = useState( + editingAutomation?.triggerType ?? prefill?.triggerType ?? 'github_pr' + ); + const [branchFilter, setBranchFilter] = useState( + editingAutomation?.triggerConfig?.branchFilter ?? '' + ); + const [labelFilter, setLabelFilter] = useState( + editingAutomation?.triggerConfig?.labelFilter?.join(', ') ?? '' + ); + const [scheduleType, setScheduleType] = useState( + editingAutomation?.schedule.type ?? 'daily' + ); + const [hour, setHour] = useState(editingAutomation?.schedule.hour ?? 9); + const [minute, setMinute] = useState(editingAutomation?.schedule.minute ?? 0); + const [dayOfWeek, setDayOfWeek] = useState( + editingAutomation?.schedule.dayOfWeek ?? 'mon' + ); + const [dayOfMonth, setDayOfMonth] = useState(editingAutomation?.schedule.dayOfMonth ?? 1); + const [useWorktree, setUseWorktree] = useState(editingAutomation?.useWorktree ?? true); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + const nameRef = useRef(null); + const userTouchedWorktreeRef = useRef(false); + + useEffect(() => { + nameRef.current?.focus(); + }, []); + + const currentSchedule = buildSchedule(scheduleType, hour, minute, dayOfWeek, dayOfMonth); + const schedulePreview = formatScheduleLabel(currentSchedule); + + let buttonLabel: string; + if (isSaving) { + buttonLabel = isEditing ? 'Saving…' : 'Creating…'; + } else { + buttonLabel = isEditing ? 'Save' : 'Create'; + } + + const handleSubmit = async () => { + setError(null); + if (!name.trim()) { + setError('Name is required'); + return; + } + if (!projectId) { + setError('Select a project'); + return; + } + if (!prompt.trim()) { + setError('Prompt is required'); + return; + } + + setIsSaving(true); + try { + if (isEditing && !onUpdate) { + throw new Error('onUpdate handler is required when editing an automation'); + } + const parsedLabels = labelFilter + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const triggerCfg = + mode === 'trigger' + ? { + branchFilter: branchFilter.trim() || undefined, + labelFilter: parsedLabels.length > 0 ? parsedLabels : undefined, + } + : undefined; + + if (isEditing && editingAutomation && onUpdate) { + await onUpdate({ + id: editingAutomation.id, + name: name.trim(), + projectId, + prompt: prompt.trim(), + agentId, + mode, + schedule: currentSchedule, + triggerType: mode === 'trigger' ? triggerType : null, + triggerConfig: triggerCfg ?? null, + useWorktree, + }); + } else { + await onSave({ + name: name.trim(), + projectId, + prompt: prompt.trim(), + agentId, + mode, + schedule: currentSchedule, + triggerType: mode === 'trigger' ? triggerType : undefined, + triggerConfig: triggerCfg, + useWorktree, + }); + } + } catch (err) { + if (err instanceof Error) { + setError(err.message); + } else { + setError(isEditing ? 'Failed to save' : 'Failed to create'); + } + } finally { + setIsSaving(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + } + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + void handleSubmit(); + } + }; + + const selectedProject = projects.find((p) => p.id === projectId); + const hasGithub = + selectedProject?.githubInfo?.connected && selectedProject?.githubInfo?.repository; + + return ( +
+ {/* Title row */} +
+ setName(e.target.value)} + placeholder="Automation title" + className="!border-0 !bg-transparent !px-0 !text-sm !font-medium !shadow-none !outline-none !ring-0 placeholder:text-muted-foreground/40 focus:!border-transparent focus:!outline-none focus:!ring-0 focus-visible:!border-transparent focus-visible:!outline-none focus-visible:!ring-0 focus-visible:!ring-offset-0" + /> +
+ + {/* Prompt textarea */} +
+