From 1baa25089eea236d7a3636d4acc5209aede8d04e Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Tue, 24 Jun 2025 01:05:55 +0100 Subject: [PATCH] Squashed commit of the following: commit 93babc04486e64ae55c106478d5c04a9ec891c1f Author: Louis Knight-Webb Date: Tue Jun 24 01:05:31 2025 +0100 UX commit 91c93187290e4e0882018c392dd744eba7cd2193 Author: Louis Knight-Webb Date: Tue Jun 24 01:03:39 2025 +0100 Update TaskDetailsPanel.tsx Follow up UI commit b66cfbfa727eb7d69b2250102712d6169a3af3b1 Author: Louis Knight-Webb Date: Tue Jun 24 00:58:21 2025 +0100 Tweaks commit aa2235c56413ffe88c4ec1bf7950012c019f9455 Author: Louis Knight-Webb Date: Tue Jun 24 00:34:02 2025 +0100 Add follow up endpoint commit 1b536e33c956e39881d5ddfd169d229cfba99c20 Author: Louis Knight-Webb Date: Tue Jun 24 00:12:55 2025 +0100 Track executor type commit 1c5d208f62fce2ed36e04384e139884e85dcb295 Author: Louis Knight-Webb Date: Mon Jun 23 16:56:58 2025 +0100 Add executor_session commit 8e305953afb71d096079587df94cf5e63c4c6a04 Author: Louis Knight-Webb Date: Mon Jun 23 16:49:07 2025 +0100 Fix type issue commit bc2dcf4fd4926ca2a42d71cd429de66fd1215208 Author: Louis Knight-Webb Date: Mon Jun 23 16:03:27 2025 +0100 Refactor --- ...e38ce16752cb73adf0fafc5b497a88f32f531.json | 56 ++ ...2f7a5bd7f81ad8faf0f9fcbb5f1c2d35dd67f.json | 56 ++ ...c2d3176019a68c6bb83d33008594821415a57.json | 12 + ...61abacfe62e758abe7030a6aa745140b95ca.json} | 38 +- ...ca5a1a8e327d9b469b47d833befa95377dfab.json | 56 ++ ...9a14093f7ce2585bf9843585608f17ec575b.json} | 40 +- ...c4cfe15dba6dcacbb9e9bc10228a8af6da619.json | 12 + ...c44252468c5226b2cdd7770f027332eed6d7.json} | 38 +- ...ae0393150f50be3f419a981e035c0121dfc7.json} | 38 +- ...87ea9d354da0873af22e888d0bf8e0c7f306a.json | 56 ++ ...f4ea8ae5dbe2ac138ea3b4470f2d5cd734846.json | 12 + .../20250623120000_executor_sessions.sql | 17 + ...d_executor_type_to_execution_processes.sql | 4 + backend/src/bin/generate_types.rs | 52 +- backend/src/executor.rs | 126 +++++ backend/src/executors/amp.rs | 44 +- backend/src/executors/claude.rs | 35 ++ backend/src/executors/mod.rs | 4 +- backend/src/models/config.rs | 31 ++ backend/src/models/execution_process.rs | 11 +- backend/src/models/executor_session.rs | 189 +++++++ backend/src/models/mod.rs | 1 + backend/src/models/task_attempt.rs | 477 ++++++++++++++---- backend/src/routes/task_attempts.rs | 56 +- .../src/components/tasks/TaskDetailsPanel.tsx | 132 +++-- shared/types.ts | 66 ++- 26 files changed, 1434 insertions(+), 225 deletions(-) create mode 100644 backend/.sqlx/query-0468aa522ed7fd2675bcf278f6be38ce16752cb73adf0fafc5b497a88f32f531.json create mode 100644 backend/.sqlx/query-06ca282915d0db9125769b1bca92f7a5bd7f81ad8faf0f9fcbb5f1c2d35dd67f.json create mode 100644 backend/.sqlx/query-1b082630a9622f8667ee7a9aba2c2d3176019a68c6bb83d33008594821415a57.json rename backend/.sqlx/{query-d25396768d88ecab6e13ad9fca8e8c46e92ff17474ebd24657384e130c49afa8.json => query-1f619f01f46859a64ded531dd0ef61abacfe62e758abe7030a6aa745140b95ca.json} (70%) create mode 100644 backend/.sqlx/query-3b65c0f6215229f3c8d487c204bca5a1a8e327d9b469b47d833befa95377dfab.json rename backend/.sqlx/{query-b50af42f635dec3167508f3c4f81d03911102a603ac94b22a431a513d36471b0.json => query-5ed1238e52e59bb5f76c0f153fd99a14093f7ce2585bf9843585608f17ec575b.json} (59%) create mode 100644 backend/.sqlx/query-77857828471571c0584bfed1641c4cfe15dba6dcacbb9e9bc10228a8af6da619.json rename backend/.sqlx/{query-1ada5613889792cd6098da71ce2ba1ecdee7e5dc2ff8196872368fff0caa48d8.json => query-9472c8fb477958167f5fae40b85ac44252468c5226b2cdd7770f027332eed6d7.json} (70%) rename backend/.sqlx/{query-14ad9267623a3aa678a943db6d0b14581a6d353da673c9e4156e0bbeac0b3346.json => query-9edb2c01e91fd0f0fe7b56e988c7ae0393150f50be3f419a981e035c0121dfc7.json} (71%) create mode 100644 backend/.sqlx/query-a528a9926fab1c819a5a1fa1cde87ea9d354da0873af22e888d0bf8e0c7f306a.json create mode 100644 backend/.sqlx/query-d3b9ea1de1576af71b312924ce7f4ea8ae5dbe2ac138ea3b4470f2d5cd734846.json create mode 100644 backend/migrations/20250623120000_executor_sessions.sql create mode 100644 backend/migrations/20250623130000_add_executor_type_to_execution_processes.sql create mode 100644 backend/src/models/executor_session.rs diff --git a/backend/.sqlx/query-0468aa522ed7fd2675bcf278f6be38ce16752cb73adf0fafc5b497a88f32f531.json b/backend/.sqlx/query-0468aa522ed7fd2675bcf278f6be38ce16752cb73adf0fafc5b497a88f32f531.json new file mode 100644 index 00000000..da199ecc --- /dev/null +++ b/backend/.sqlx/query-0468aa522ed7fd2675bcf278f6be38ce16752cb73adf0fafc5b497a88f32f531.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n execution_process_id as \"execution_process_id!: Uuid\", \n session_id, \n prompt,\n created_at as \"created_at!: DateTime\", \n updated_at as \"updated_at!: DateTime\"\n FROM executor_sessions \n WHERE execution_process_id = $1", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "task_attempt_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "execution_process_id!: Uuid", + "ordinal": 2, + "type_info": "Blob" + }, + { + "name": "session_id", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "prompt", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + true, + true, + false, + false + ] + }, + "hash": "0468aa522ed7fd2675bcf278f6be38ce16752cb73adf0fafc5b497a88f32f531" +} diff --git a/backend/.sqlx/query-06ca282915d0db9125769b1bca92f7a5bd7f81ad8faf0f9fcbb5f1c2d35dd67f.json b/backend/.sqlx/query-06ca282915d0db9125769b1bca92f7a5bd7f81ad8faf0f9fcbb5f1c2d35dd67f.json new file mode 100644 index 00000000..aaa01c38 --- /dev/null +++ b/backend/.sqlx/query-06ca282915d0db9125769b1bca92f7a5bd7f81ad8faf0f9fcbb5f1c2d35dd67f.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n execution_process_id as \"execution_process_id!: Uuid\", \n session_id, \n prompt,\n created_at as \"created_at!: DateTime\", \n updated_at as \"updated_at!: DateTime\"\n FROM executor_sessions \n WHERE task_attempt_id = $1 \n ORDER BY created_at ASC", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "task_attempt_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "execution_process_id!: Uuid", + "ordinal": 2, + "type_info": "Blob" + }, + { + "name": "session_id", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "prompt", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + true, + true, + false, + false + ] + }, + "hash": "06ca282915d0db9125769b1bca92f7a5bd7f81ad8faf0f9fcbb5f1c2d35dd67f" +} diff --git a/backend/.sqlx/query-1b082630a9622f8667ee7a9aba2c2d3176019a68c6bb83d33008594821415a57.json b/backend/.sqlx/query-1b082630a9622f8667ee7a9aba2c2d3176019a68c6bb83d33008594821415a57.json new file mode 100644 index 00000000..0eafcab4 --- /dev/null +++ b/backend/.sqlx/query-1b082630a9622f8667ee7a9aba2c2d3176019a68c6bb83d33008594821415a57.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM executor_sessions WHERE task_attempt_id = $1", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "1b082630a9622f8667ee7a9aba2c2d3176019a68c6bb83d33008594821415a57" +} diff --git a/backend/.sqlx/query-d25396768d88ecab6e13ad9fca8e8c46e92ff17474ebd24657384e130c49afa8.json b/backend/.sqlx/query-1f619f01f46859a64ded531dd0ef61abacfe62e758abe7030a6aa745140b95ca.json similarity index 70% rename from backend/.sqlx/query-d25396768d88ecab6e13ad9fca8e8c46e92ff17474ebd24657384e130c49afa8.json rename to backend/.sqlx/query-1f619f01f46859a64ded531dd0ef61abacfe62e758abe7030a6aa745140b95ca.json index 6266c44d..faa7786f 100644 --- a/backend/.sqlx/query-d25396768d88ecab6e13ad9fca8e8c46e92ff17474ebd24657384e130c49afa8.json +++ b/backend/.sqlx/query-1f619f01f46859a64ded531dd0ef61abacfe62e758abe7030a6aa745140b95ca.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n process_type as \"process_type!: ExecutionProcessType\",\n status as \"status!: ExecutionProcessStatus\",\n command, \n args, \n working_directory, \n stdout, \n stderr, \n exit_code,\n started_at as \"started_at!: DateTime\",\n completed_at as \"completed_at?: DateTime\",\n created_at as \"created_at!: DateTime\", \n updated_at as \"updated_at!: DateTime\"\n FROM execution_processes \n WHERE status = 'running' \n ORDER BY created_at ASC", + "query": "SELECT \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n process_type as \"process_type!: ExecutionProcessType\",\n executor_type,\n status as \"status!: ExecutionProcessStatus\",\n command, \n args, \n working_directory, \n stdout, \n stderr, \n exit_code,\n started_at as \"started_at!: DateTime\",\n completed_at as \"completed_at?: DateTime\",\n created_at as \"created_at!: DateTime\", \n updated_at as \"updated_at!: DateTime\"\n FROM execution_processes \n WHERE status = 'running' \n ORDER BY created_at ASC", "describe": { "columns": [ { @@ -19,59 +19,64 @@ "type_info": "Text" }, { - "name": "status!: ExecutionProcessStatus", + "name": "executor_type", "ordinal": 3, "type_info": "Text" }, { - "name": "command", + "name": "status!: ExecutionProcessStatus", "ordinal": 4, "type_info": "Text" }, { - "name": "args", + "name": "command", "ordinal": 5, "type_info": "Text" }, { - "name": "working_directory", + "name": "args", "ordinal": 6, "type_info": "Text" }, { - "name": "stdout", + "name": "working_directory", "ordinal": 7, "type_info": "Text" }, { - "name": "stderr", + "name": "stdout", "ordinal": 8, "type_info": "Text" }, { - "name": "exit_code", + "name": "stderr", "ordinal": 9, + "type_info": "Text" + }, + { + "name": "exit_code", + "ordinal": 10, "type_info": "Integer" }, { "name": "started_at!: DateTime", - "ordinal": 10, - "type_info": "Text" - }, - { - "name": "completed_at?: DateTime", "ordinal": 11, "type_info": "Text" }, { - "name": "created_at!: DateTime", + "name": "completed_at?: DateTime", "ordinal": 12, "type_info": "Text" }, { - "name": "updated_at!: DateTime", + "name": "created_at!: DateTime", "ordinal": 13, "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 14, + "type_info": "Text" } ], "parameters": { @@ -81,6 +86,7 @@ true, false, false, + true, false, false, true, @@ -94,5 +100,5 @@ false ] }, - "hash": "d25396768d88ecab6e13ad9fca8e8c46e92ff17474ebd24657384e130c49afa8" + "hash": "1f619f01f46859a64ded531dd0ef61abacfe62e758abe7030a6aa745140b95ca" } diff --git a/backend/.sqlx/query-3b65c0f6215229f3c8d487c204bca5a1a8e327d9b469b47d833befa95377dfab.json b/backend/.sqlx/query-3b65c0f6215229f3c8d487c204bca5a1a8e327d9b469b47d833befa95377dfab.json new file mode 100644 index 00000000..c68f35ad --- /dev/null +++ b/backend/.sqlx/query-3b65c0f6215229f3c8d487c204bca5a1a8e327d9b469b47d833befa95377dfab.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n execution_process_id as \"execution_process_id!: Uuid\", \n session_id, \n prompt,\n created_at as \"created_at!: DateTime\", \n updated_at as \"updated_at!: DateTime\"\n FROM executor_sessions \n WHERE id = $1", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "task_attempt_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "execution_process_id!: Uuid", + "ordinal": 2, + "type_info": "Blob" + }, + { + "name": "session_id", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "prompt", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + true, + true, + false, + false + ] + }, + "hash": "3b65c0f6215229f3c8d487c204bca5a1a8e327d9b469b47d833befa95377dfab" +} diff --git a/backend/.sqlx/query-b50af42f635dec3167508f3c4f81d03911102a603ac94b22a431a513d36471b0.json b/backend/.sqlx/query-5ed1238e52e59bb5f76c0f153fd99a14093f7ce2585bf9843585608f17ec575b.json similarity index 59% rename from backend/.sqlx/query-b50af42f635dec3167508f3c4f81d03911102a603ac94b22a431a513d36471b0.json rename to backend/.sqlx/query-5ed1238e52e59bb5f76c0f153fd99a14093f7ce2585bf9843585608f17ec575b.json index c518642f..b8eeb4f0 100644 --- a/backend/.sqlx/query-b50af42f635dec3167508f3c4f81d03911102a603ac94b22a431a513d36471b0.json +++ b/backend/.sqlx/query-5ed1238e52e59bb5f76c0f153fd99a14093f7ce2585bf9843585608f17ec575b.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "INSERT INTO execution_processes (\n id, task_attempt_id, process_type, status, command, args, \n working_directory, stdout, stderr, exit_code, started_at, \n completed_at, created_at, updated_at\n ) \n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) \n RETURNING \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n process_type as \"process_type!: ExecutionProcessType\",\n status as \"status!: ExecutionProcessStatus\",\n command, \n args, \n working_directory, \n stdout, \n stderr, \n exit_code,\n started_at as \"started_at!: DateTime\",\n completed_at as \"completed_at?: DateTime\",\n created_at as \"created_at!: DateTime\", \n updated_at as \"updated_at!: DateTime\"", + "query": "INSERT INTO execution_processes (\n id, task_attempt_id, process_type, executor_type, status, command, args, \n working_directory, stdout, stderr, exit_code, started_at, \n completed_at, created_at, updated_at\n ) \n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) \n RETURNING \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n process_type as \"process_type!: ExecutionProcessType\",\n executor_type,\n status as \"status!: ExecutionProcessStatus\",\n command, \n args, \n working_directory, \n stdout, \n stderr, \n exit_code,\n started_at as \"started_at!: DateTime\",\n completed_at as \"completed_at?: DateTime\",\n created_at as \"created_at!: DateTime\", \n updated_at as \"updated_at!: DateTime\"", "describe": { "columns": [ { @@ -19,68 +19,74 @@ "type_info": "Text" }, { - "name": "status!: ExecutionProcessStatus", + "name": "executor_type", "ordinal": 3, "type_info": "Text" }, { - "name": "command", + "name": "status!: ExecutionProcessStatus", "ordinal": 4, "type_info": "Text" }, { - "name": "args", + "name": "command", "ordinal": 5, "type_info": "Text" }, { - "name": "working_directory", + "name": "args", "ordinal": 6, "type_info": "Text" }, { - "name": "stdout", + "name": "working_directory", "ordinal": 7, "type_info": "Text" }, { - "name": "stderr", + "name": "stdout", "ordinal": 8, "type_info": "Text" }, { - "name": "exit_code", + "name": "stderr", "ordinal": 9, + "type_info": "Text" + }, + { + "name": "exit_code", + "ordinal": 10, "type_info": "Integer" }, { "name": "started_at!: DateTime", - "ordinal": 10, - "type_info": "Text" - }, - { - "name": "completed_at?: DateTime", "ordinal": 11, "type_info": "Text" }, { - "name": "created_at!: DateTime", + "name": "completed_at?: DateTime", "ordinal": 12, "type_info": "Text" }, { - "name": "updated_at!: DateTime", + "name": "created_at!: DateTime", "ordinal": 13, "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 14, + "type_info": "Text" } ], "parameters": { - "Right": 14 + "Right": 15 }, "nullable": [ true, false, false, + true, false, false, true, @@ -94,5 +100,5 @@ false ] }, - "hash": "b50af42f635dec3167508f3c4f81d03911102a603ac94b22a431a513d36471b0" + "hash": "5ed1238e52e59bb5f76c0f153fd99a14093f7ce2585bf9843585608f17ec575b" } diff --git a/backend/.sqlx/query-77857828471571c0584bfed1641c4cfe15dba6dcacbb9e9bc10228a8af6da619.json b/backend/.sqlx/query-77857828471571c0584bfed1641c4cfe15dba6dcacbb9e9bc10228a8af6da619.json new file mode 100644 index 00000000..8a35d550 --- /dev/null +++ b/backend/.sqlx/query-77857828471571c0584bfed1641c4cfe15dba6dcacbb9e9bc10228a8af6da619.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE executor_sessions \n SET session_id = $1, updated_at = datetime('now') \n WHERE execution_process_id = $2", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "77857828471571c0584bfed1641c4cfe15dba6dcacbb9e9bc10228a8af6da619" +} diff --git a/backend/.sqlx/query-1ada5613889792cd6098da71ce2ba1ecdee7e5dc2ff8196872368fff0caa48d8.json b/backend/.sqlx/query-9472c8fb477958167f5fae40b85ac44252468c5226b2cdd7770f027332eed6d7.json similarity index 70% rename from backend/.sqlx/query-1ada5613889792cd6098da71ce2ba1ecdee7e5dc2ff8196872368fff0caa48d8.json rename to backend/.sqlx/query-9472c8fb477958167f5fae40b85ac44252468c5226b2cdd7770f027332eed6d7.json index 6ed459be..98d4db3b 100644 --- a/backend/.sqlx/query-1ada5613889792cd6098da71ce2ba1ecdee7e5dc2ff8196872368fff0caa48d8.json +++ b/backend/.sqlx/query-9472c8fb477958167f5fae40b85ac44252468c5226b2cdd7770f027332eed6d7.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n process_type as \"process_type!: ExecutionProcessType\",\n status as \"status!: ExecutionProcessStatus\",\n command, \n args, \n working_directory, \n stdout, \n stderr, \n exit_code,\n started_at as \"started_at!: DateTime\",\n completed_at as \"completed_at?: DateTime\",\n created_at as \"created_at!: DateTime\", \n updated_at as \"updated_at!: DateTime\"\n FROM execution_processes \n WHERE id = $1", + "query": "SELECT \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n process_type as \"process_type!: ExecutionProcessType\",\n executor_type,\n status as \"status!: ExecutionProcessStatus\",\n command, \n args, \n working_directory, \n stdout, \n stderr, \n exit_code,\n started_at as \"started_at!: DateTime\",\n completed_at as \"completed_at?: DateTime\",\n created_at as \"created_at!: DateTime\", \n updated_at as \"updated_at!: DateTime\"\n FROM execution_processes \n WHERE task_attempt_id = $1 \n ORDER BY created_at ASC", "describe": { "columns": [ { @@ -19,59 +19,64 @@ "type_info": "Text" }, { - "name": "status!: ExecutionProcessStatus", + "name": "executor_type", "ordinal": 3, "type_info": "Text" }, { - "name": "command", + "name": "status!: ExecutionProcessStatus", "ordinal": 4, "type_info": "Text" }, { - "name": "args", + "name": "command", "ordinal": 5, "type_info": "Text" }, { - "name": "working_directory", + "name": "args", "ordinal": 6, "type_info": "Text" }, { - "name": "stdout", + "name": "working_directory", "ordinal": 7, "type_info": "Text" }, { - "name": "stderr", + "name": "stdout", "ordinal": 8, "type_info": "Text" }, { - "name": "exit_code", + "name": "stderr", "ordinal": 9, + "type_info": "Text" + }, + { + "name": "exit_code", + "ordinal": 10, "type_info": "Integer" }, { "name": "started_at!: DateTime", - "ordinal": 10, - "type_info": "Text" - }, - { - "name": "completed_at?: DateTime", "ordinal": 11, "type_info": "Text" }, { - "name": "created_at!: DateTime", + "name": "completed_at?: DateTime", "ordinal": 12, "type_info": "Text" }, { - "name": "updated_at!: DateTime", + "name": "created_at!: DateTime", "ordinal": 13, "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 14, + "type_info": "Text" } ], "parameters": { @@ -81,6 +86,7 @@ true, false, false, + true, false, false, true, @@ -94,5 +100,5 @@ false ] }, - "hash": "1ada5613889792cd6098da71ce2ba1ecdee7e5dc2ff8196872368fff0caa48d8" + "hash": "9472c8fb477958167f5fae40b85ac44252468c5226b2cdd7770f027332eed6d7" } diff --git a/backend/.sqlx/query-14ad9267623a3aa678a943db6d0b14581a6d353da673c9e4156e0bbeac0b3346.json b/backend/.sqlx/query-9edb2c01e91fd0f0fe7b56e988c7ae0393150f50be3f419a981e035c0121dfc7.json similarity index 71% rename from backend/.sqlx/query-14ad9267623a3aa678a943db6d0b14581a6d353da673c9e4156e0bbeac0b3346.json rename to backend/.sqlx/query-9edb2c01e91fd0f0fe7b56e988c7ae0393150f50be3f419a981e035c0121dfc7.json index 4d18d962..d2b42366 100644 --- a/backend/.sqlx/query-14ad9267623a3aa678a943db6d0b14581a6d353da673c9e4156e0bbeac0b3346.json +++ b/backend/.sqlx/query-9edb2c01e91fd0f0fe7b56e988c7ae0393150f50be3f419a981e035c0121dfc7.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n process_type as \"process_type!: ExecutionProcessType\",\n status as \"status!: ExecutionProcessStatus\",\n command, \n args, \n working_directory, \n stdout, \n stderr, \n exit_code,\n started_at as \"started_at!: DateTime\",\n completed_at as \"completed_at?: DateTime\",\n created_at as \"created_at!: DateTime\", \n updated_at as \"updated_at!: DateTime\"\n FROM execution_processes \n WHERE task_attempt_id = $1 \n ORDER BY created_at ASC", + "query": "SELECT \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n process_type as \"process_type!: ExecutionProcessType\",\n executor_type,\n status as \"status!: ExecutionProcessStatus\",\n command, \n args, \n working_directory, \n stdout, \n stderr, \n exit_code,\n started_at as \"started_at!: DateTime\",\n completed_at as \"completed_at?: DateTime\",\n created_at as \"created_at!: DateTime\", \n updated_at as \"updated_at!: DateTime\"\n FROM execution_processes \n WHERE id = $1", "describe": { "columns": [ { @@ -19,59 +19,64 @@ "type_info": "Text" }, { - "name": "status!: ExecutionProcessStatus", + "name": "executor_type", "ordinal": 3, "type_info": "Text" }, { - "name": "command", + "name": "status!: ExecutionProcessStatus", "ordinal": 4, "type_info": "Text" }, { - "name": "args", + "name": "command", "ordinal": 5, "type_info": "Text" }, { - "name": "working_directory", + "name": "args", "ordinal": 6, "type_info": "Text" }, { - "name": "stdout", + "name": "working_directory", "ordinal": 7, "type_info": "Text" }, { - "name": "stderr", + "name": "stdout", "ordinal": 8, "type_info": "Text" }, { - "name": "exit_code", + "name": "stderr", "ordinal": 9, + "type_info": "Text" + }, + { + "name": "exit_code", + "ordinal": 10, "type_info": "Integer" }, { "name": "started_at!: DateTime", - "ordinal": 10, - "type_info": "Text" - }, - { - "name": "completed_at?: DateTime", "ordinal": 11, "type_info": "Text" }, { - "name": "created_at!: DateTime", + "name": "completed_at?: DateTime", "ordinal": 12, "type_info": "Text" }, { - "name": "updated_at!: DateTime", + "name": "created_at!: DateTime", "ordinal": 13, "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 14, + "type_info": "Text" } ], "parameters": { @@ -81,6 +86,7 @@ true, false, false, + true, false, false, true, @@ -94,5 +100,5 @@ false ] }, - "hash": "14ad9267623a3aa678a943db6d0b14581a6d353da673c9e4156e0bbeac0b3346" + "hash": "9edb2c01e91fd0f0fe7b56e988c7ae0393150f50be3f419a981e035c0121dfc7" } diff --git a/backend/.sqlx/query-a528a9926fab1c819a5a1fa1cde87ea9d354da0873af22e888d0bf8e0c7f306a.json b/backend/.sqlx/query-a528a9926fab1c819a5a1fa1cde87ea9d354da0873af22e888d0bf8e0c7f306a.json new file mode 100644 index 00000000..1437e561 --- /dev/null +++ b/backend/.sqlx/query-a528a9926fab1c819a5a1fa1cde87ea9d354da0873af22e888d0bf8e0c7f306a.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO executor_sessions (\n id, task_attempt_id, execution_process_id, session_id, prompt, \n created_at, updated_at\n ) \n VALUES ($1, $2, $3, $4, $5, $6, $7) \n RETURNING \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n execution_process_id as \"execution_process_id!: Uuid\", \n session_id, \n prompt,\n created_at as \"created_at!: DateTime\", \n updated_at as \"updated_at!: DateTime\"", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "task_attempt_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "execution_process_id!: Uuid", + "ordinal": 2, + "type_info": "Blob" + }, + { + "name": "session_id", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "prompt", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 7 + }, + "nullable": [ + true, + false, + false, + true, + true, + false, + false + ] + }, + "hash": "a528a9926fab1c819a5a1fa1cde87ea9d354da0873af22e888d0bf8e0c7f306a" +} diff --git a/backend/.sqlx/query-d3b9ea1de1576af71b312924ce7f4ea8ae5dbe2ac138ea3b4470f2d5cd734846.json b/backend/.sqlx/query-d3b9ea1de1576af71b312924ce7f4ea8ae5dbe2ac138ea3b4470f2d5cd734846.json new file mode 100644 index 00000000..51fc4464 --- /dev/null +++ b/backend/.sqlx/query-d3b9ea1de1576af71b312924ce7f4ea8ae5dbe2ac138ea3b4470f2d5cd734846.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE executor_sessions \n SET prompt = $1, updated_at = datetime('now') \n WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "d3b9ea1de1576af71b312924ce7f4ea8ae5dbe2ac138ea3b4470f2d5cd734846" +} diff --git a/backend/migrations/20250623120000_executor_sessions.sql b/backend/migrations/20250623120000_executor_sessions.sql new file mode 100644 index 00000000..f7e793d4 --- /dev/null +++ b/backend/migrations/20250623120000_executor_sessions.sql @@ -0,0 +1,17 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE executor_sessions ( + id BLOB PRIMARY KEY, + task_attempt_id BLOB NOT NULL, + execution_process_id BLOB NOT NULL, + session_id TEXT, -- External session ID from Claude/Amp + prompt TEXT, -- The prompt sent to the executor + created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), + FOREIGN KEY (task_attempt_id) REFERENCES task_attempts(id) ON DELETE CASCADE, + FOREIGN KEY (execution_process_id) REFERENCES execution_processes(id) ON DELETE CASCADE +); + +CREATE INDEX idx_executor_sessions_task_attempt_id ON executor_sessions(task_attempt_id); +CREATE INDEX idx_executor_sessions_execution_process_id ON executor_sessions(execution_process_id); +CREATE INDEX idx_executor_sessions_session_id ON executor_sessions(session_id); diff --git a/backend/migrations/20250623130000_add_executor_type_to_execution_processes.sql b/backend/migrations/20250623130000_add_executor_type_to_execution_processes.sql new file mode 100644 index 00000000..8be18205 --- /dev/null +++ b/backend/migrations/20250623130000_add_executor_type_to_execution_processes.sql @@ -0,0 +1,4 @@ +PRAGMA foreign_keys = ON; + +-- Add executor_type column to execution_processes table +ALTER TABLE execution_processes ADD COLUMN executor_type TEXT; diff --git a/backend/src/bin/generate_types.rs b/backend/src/bin/generate_types.rs index 8b39625b..e8f271cb 100644 --- a/backend/src/bin/generate_types.rs +++ b/backend/src/bin/generate_types.rs @@ -1,6 +1,40 @@ use std::{env, fs, path::Path}; use ts_rs::TS; // in [build-dependencies] +fn generate_constants() -> String { + r#"// Generated constants +export const EXECUTOR_TYPES: string[] = [ + "echo", + "claude", + "amp" +]; + +export const EDITOR_TYPES: EditorType[] = [ + "vscode", + "cursor", + "windsurf", + "intellij", + "zed", + "custom" +]; + +export const EXECUTOR_LABELS: Record = { + "echo": "Echo (Test Mode)", + "claude": "Claude", + "amp": "Amp" +}; + +export const EDITOR_LABELS: Record = { + "vscode": "VS Code", + "cursor": "Cursor", + "windsurf": "Windsurf", + "intellij": "IntelliJ IDEA", + "zed": "Zed", + "custom": "Custom" +};"# + .to_string() +} + fn main() { // 1. Make sure ../shared exists let shared_path = Path::new("../shared"); @@ -18,7 +52,9 @@ fn main() { vibe_kanban::models::config::ThemeMode::decl(), vibe_kanban::models::config::EditorConfig::decl(), vibe_kanban::models::config::EditorType::decl(), + vibe_kanban::models::config::EditorConstants::decl(), vibe_kanban::executor::ExecutorConfig::decl(), + vibe_kanban::executor::ExecutorConstants::decl(), vibe_kanban::models::project::CreateProject::decl(), vibe_kanban::models::project::Project::decl(), vibe_kanban::models::project::UpdateProject::decl(), @@ -34,6 +70,7 @@ fn main() { vibe_kanban::models::task_attempt::TaskAttempt::decl(), vibe_kanban::models::task_attempt::CreateTaskAttempt::decl(), vibe_kanban::models::task_attempt::UpdateTaskAttempt::decl(), + vibe_kanban::models::task_attempt::CreateFollowUpAttempt::decl(), vibe_kanban::models::task_attempt_activity::TaskAttemptActivity::decl(), vibe_kanban::models::task_attempt_activity::CreateTaskAttemptActivity::decl(), vibe_kanban::routes::filesystem::DirectoryEntry::decl(), @@ -47,6 +84,9 @@ fn main() { vibe_kanban::models::execution_process::ExecutionProcessType::decl(), vibe_kanban::models::execution_process::CreateExecutionProcess::decl(), vibe_kanban::models::execution_process::UpdateExecutionProcess::decl(), + vibe_kanban::models::executor_session::ExecutorSession::decl(), + vibe_kanban::models::executor_session::CreateExecutorSession::decl(), + vibe_kanban::models::executor_session::UpdateExecutorSession::decl(), ]; // 4. Friendly banner @@ -69,9 +109,15 @@ fn main() { .collect::>() .join("\n\n"); - // 6. Write the consolidated types.ts - fs::write(shared_path.join("types.ts"), format!("{HEADER}{body}")) - .expect("unable to write types.ts"); + // 6. Add constants + let constants = generate_constants(); + + // 7. Write the consolidated types.ts + fs::write( + shared_path.join("types.ts"), + format!("{HEADER}{body}\n\n{constants}"), + ) + .expect("unable to write types.ts"); println!("✅ TypeScript types generated in ../shared/"); } diff --git a/backend/src/executor.rs b/backend/src/executor.rs index 390dc34f..e2828f9f 100644 --- a/backend/src/executor.rs +++ b/backend/src/executor.rs @@ -92,6 +92,11 @@ pub trait Executor: Send + Sync { pub enum ExecutorType { SetupScript(String), CodingAgent(ExecutorConfig), + FollowUpCodingAgent { + config: ExecutorConfig, + session_id: Option, + prompt: String, + }, } /// Configuration for different executor types @@ -107,6 +112,31 @@ pub enum ExecutorConfig { // Docker { image: String, command: String }, } +// Constants for frontend +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ExecutorConstants { + pub executor_types: Vec, + pub executor_labels: Vec, +} + +impl ExecutorConstants { + pub fn new() -> Self { + Self { + executor_types: vec![ + ExecutorConfig::Echo, + ExecutorConfig::Claude, + ExecutorConfig::Amp, + ], + executor_labels: vec![ + "Echo (Test Mode)".to_string(), + "Claude".to_string(), + "Amp".to_string(), + ], + } + } +} + impl ExecutorConfig { pub fn create_executor(&self) -> Box { match self { @@ -126,17 +156,45 @@ pub async fn stream_output_to_db( is_stdout: bool, ) { use crate::models::execution_process::ExecutionProcess; + use crate::models::executor_session::ExecutorSession; let mut reader = BufReader::new(output); let mut line = String::new(); let mut accumulated_output = String::new(); let mut update_counter = 0; + let mut session_id_parsed = false; loop { line.clear(); match reader.read_line(&mut line).await { Ok(0) => break, // EOF Ok(_) => { + // Parse session ID from the first JSONL line (stdout only) + if is_stdout && !session_id_parsed { + if let Some(external_session_id) = parse_session_id_from_line(&line) { + if let Err(e) = ExecutorSession::update_session_id( + &pool, + execution_process_id, + &external_session_id, + ) + .await + { + tracing::error!( + "Failed to update session ID for execution process {}: {}", + execution_process_id, + e + ); + } else { + tracing::info!( + "Updated session ID {} for execution process {}", + external_session_id, + execution_process_id + ); + } + session_id_parsed = true; + } + } + accumulated_output.push_str(&line); update_counter += 1; @@ -208,3 +266,71 @@ pub async fn stream_output_to_db( } } } + +/// Parse session_id from Claude or thread_id from Amp from the first JSONL line +fn parse_session_id_from_line(line: &str) -> Option { + use serde_json::Value; + + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + + // Try to parse as JSON + if let Ok(json) = serde_json::from_str::(trimmed) { + // Check for Claude session_id + if let Some(session_id) = json.get("session_id").and_then(|v| v.as_str()) { + return Some(session_id.to_string()); + } + + // Check for Amp threadID + if let Some(thread_id) = json.get("threadID").and_then(|v| v.as_str()) { + return Some(thread_id.to_string()); + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_claude_session_id() { + let claude_line = r#"{"type":"system","subtype":"init","cwd":"/private/tmp/mission-control-worktree-3abb979d-2e0e-4404-a276-c16d98a97dd5","session_id":"cc0889a2-0c59-43cc-926b-739a983888a2","tools":["Task","Bash","Glob","Grep","LS","exit_plan_mode","Read","Edit","MultiEdit","Write","NotebookRead","NotebookEdit","WebFetch","TodoRead","TodoWrite","WebSearch"],"mcp_servers":[],"model":"claude-sonnet-4-20250514","permissionMode":"bypassPermissions","apiKeySource":"/login managed key"}"#; + + assert_eq!( + parse_session_id_from_line(claude_line), + Some("cc0889a2-0c59-43cc-926b-739a983888a2".to_string()) + ); + } + + #[test] + fn test_parse_amp_thread_id() { + let amp_line = r#"{"type":"initial","threadID":"T-286f908a-2cd8-40cc-9490-da689b2f1560"}"#; + + assert_eq!( + parse_session_id_from_line(amp_line), + Some("T-286f908a-2cd8-40cc-9490-da689b2f1560".to_string()) + ); + } + + #[test] + fn test_parse_invalid_json() { + let invalid_line = "not json at all"; + assert_eq!(parse_session_id_from_line(invalid_line), None); + } + + #[test] + fn test_parse_json_without_ids() { + let other_json = r#"{"type":"other","message":"hello"}"#; + assert_eq!(parse_session_id_from_line(other_json), None); + } + + #[test] + fn test_parse_empty_line() { + assert_eq!(parse_session_id_from_line(""), None); + assert_eq!(parse_session_id_from_line(" "), None); + } +} diff --git a/backend/src/executors/amp.rs b/backend/src/executors/amp.rs index 3a5e6898..88bdb66a 100644 --- a/backend/src/executors/amp.rs +++ b/backend/src/executors/amp.rs @@ -5,9 +5,15 @@ use uuid::Uuid; use crate::executor::{Executor, ExecutorError}; use crate::models::task::Task; -/// An executor that uses Claude CLI to process tasks +/// An executor that uses Amp to process tasks pub struct AmpExecutor; +/// An executor that continues an Amp thread +pub struct AmpFollowupExecutor { + pub thread_id: String, + pub prompt: String, +} + #[async_trait] impl Executor for AmpExecutor { async fn spawn( @@ -53,3 +59,39 @@ impl Executor for AmpExecutor { Ok(child) } } + +#[async_trait] +impl Executor for AmpFollowupExecutor { + async fn spawn( + &self, + pool: &sqlx::SqlitePool, + task_id: Uuid, + worktree_path: &str, + ) -> Result { + use std::process::Stdio; + use tokio::{io::AsyncWriteExt, process::Command}; + + let mut child = Command::new("npx") + .kill_on_drop(true) + .stdin(Stdio::piped()) // <-- open a pipe + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(worktree_path) + .arg("@sourcegraph/amp") + .arg("threads") + .arg("continue") + .arg(&self.thread_id) + .arg("--format=jsonl") + .process_group(0) // Create new process group so we can kill entire tree + .spawn() + .map_err(ExecutorError::SpawnFailed)?; + + // feed the prompt in, then close the pipe so `amp` sees EOF + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(self.prompt.as_bytes()).await.unwrap(); + stdin.shutdown().await.unwrap(); // or `drop(stdin);` + } + + Ok(child) + } +} diff --git a/backend/src/executors/claude.rs b/backend/src/executors/claude.rs index 0004a202..a3098bcb 100644 --- a/backend/src/executors/claude.rs +++ b/backend/src/executors/claude.rs @@ -8,6 +8,12 @@ use crate::models::task::Task; /// An executor that uses Claude CLI to process tasks pub struct ClaudeExecutor; +/// An executor that resumes a Claude session +pub struct ClaudeFollowupExecutor { + pub session_id: String, + pub prompt: String, +} + #[async_trait] impl Executor for ClaudeExecutor { async fn spawn( @@ -49,3 +55,32 @@ impl Executor for ClaudeExecutor { Ok(child) } } + +#[async_trait] +impl Executor for ClaudeFollowupExecutor { + async fn spawn( + &self, + pool: &sqlx::SqlitePool, + task_id: Uuid, + worktree_path: &str, + ) -> Result { + // Use Claude CLI with --resume flag to continue the session + let child = Command::new("claude") + .kill_on_drop(true) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .current_dir(worktree_path) + .arg(&self.prompt) + .arg("-p") + .arg("--dangerously-skip-permissions") + .arg("--verbose") + .arg("--output-format=stream-json") + .arg(format!("--resume={}", self.session_id)) + .process_group(0) // Create new process group so we can kill entire tree + .spawn() + .map_err(ExecutorError::SpawnFailed)?; + + Ok(child) + } +} diff --git a/backend/src/executors/mod.rs b/backend/src/executors/mod.rs index d0d6b133..5367400f 100644 --- a/backend/src/executors/mod.rs +++ b/backend/src/executors/mod.rs @@ -3,7 +3,7 @@ pub mod claude; pub mod echo; pub mod setup_script; -pub use amp::AmpExecutor; -pub use claude::ClaudeExecutor; +pub use amp::{AmpExecutor, AmpFollowupExecutor}; +pub use claude::{ClaudeExecutor, ClaudeFollowupExecutor}; pub use echo::EchoExecutor; pub use setup_script::SetupScriptExecutor; diff --git a/backend/src/models/config.rs b/backend/src/models/config.rs index ad5309b5..a72454d3 100644 --- a/backend/src/models/config.rs +++ b/backend/src/models/config.rs @@ -43,6 +43,37 @@ pub enum EditorType { Custom, } +// Constants for frontend +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct EditorConstants { + pub editor_types: Vec, + pub editor_labels: Vec, +} + +impl EditorConstants { + pub fn new() -> Self { + Self { + editor_types: vec![ + EditorType::VSCode, + EditorType::Cursor, + EditorType::Windsurf, + EditorType::IntelliJ, + EditorType::Zed, + EditorType::Custom, + ], + editor_labels: vec![ + "VS Code".to_string(), + "Cursor".to_string(), + "Windsurf".to_string(), + "IntelliJ IDEA".to_string(), + "Zed".to_string(), + "Custom".to_string(), + ], + } + } +} + impl Default for Config { fn default() -> Self { Self { diff --git a/backend/src/models/execution_process.rs b/backend/src/models/execution_process.rs index 2886e91d..508d8c39 100644 --- a/backend/src/models/execution_process.rs +++ b/backend/src/models/execution_process.rs @@ -53,6 +53,7 @@ pub struct ExecutionProcess { pub id: Uuid, pub task_attempt_id: Uuid, pub process_type: ExecutionProcessType, + pub executor_type: Option, // "echo", "claude", "amp", etc. - only for CodingAgent processes pub status: ExecutionProcessStatus, pub command: String, pub args: Option, // JSON array of arguments @@ -71,6 +72,7 @@ pub struct ExecutionProcess { pub struct CreateExecutionProcess { pub task_attempt_id: Uuid, pub process_type: ExecutionProcessType, + pub executor_type: Option, pub command: String, pub args: Option, pub working_directory: String, @@ -93,6 +95,7 @@ impl ExecutionProcess { id as "id!: Uuid", task_attempt_id as "task_attempt_id!: Uuid", process_type as "process_type!: ExecutionProcessType", + executor_type, status as "status!: ExecutionProcessStatus", command, args, @@ -123,6 +126,7 @@ impl ExecutionProcess { id as "id!: Uuid", task_attempt_id as "task_attempt_id!: Uuid", process_type as "process_type!: ExecutionProcessType", + executor_type, status as "status!: ExecutionProcessStatus", command, args, @@ -151,6 +155,7 @@ impl ExecutionProcess { id as "id!: Uuid", task_attempt_id as "task_attempt_id!: Uuid", process_type as "process_type!: ExecutionProcessType", + executor_type, status as "status!: ExecutionProcessStatus", command, args, @@ -181,15 +186,16 @@ impl ExecutionProcess { sqlx::query_as!( ExecutionProcess, r#"INSERT INTO execution_processes ( - id, task_attempt_id, process_type, status, command, args, + id, task_attempt_id, process_type, executor_type, status, command, args, working_directory, stdout, stderr, exit_code, started_at, completed_at, created_at, updated_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id as "id!: Uuid", task_attempt_id as "task_attempt_id!: Uuid", process_type as "process_type!: ExecutionProcessType", + executor_type, status as "status!: ExecutionProcessStatus", command, args, @@ -204,6 +210,7 @@ impl ExecutionProcess { process_id, data.task_attempt_id, data.process_type, + data.executor_type, ExecutionProcessStatus::Running, data.command, data.args, diff --git a/backend/src/models/executor_session.rs b/backend/src/models/executor_session.rs new file mode 100644 index 00000000..09db9462 --- /dev/null +++ b/backend/src/models/executor_session.rs @@ -0,0 +1,189 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, SqlitePool}; +use ts_rs::TS; +use uuid::Uuid; + +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ExecutorSession { + pub id: Uuid, + pub task_attempt_id: Uuid, + pub execution_process_id: Uuid, + pub session_id: Option, // External session ID from Claude/Amp + pub prompt: Option, // The prompt sent to the executor + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Deserialize, TS)] +#[ts(export)] +pub struct CreateExecutorSession { + pub task_attempt_id: Uuid, + pub execution_process_id: Uuid, + pub prompt: Option, +} + +#[derive(Debug, Deserialize, TS)] +#[ts(export)] +pub struct UpdateExecutorSession { + pub session_id: Option, + pub prompt: Option, +} + +impl ExecutorSession { + /// Find executor session by ID + pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!( + ExecutorSession, + r#"SELECT + id as "id!: Uuid", + task_attempt_id as "task_attempt_id!: Uuid", + execution_process_id as "execution_process_id!: Uuid", + session_id, + prompt, + created_at as "created_at!: DateTime", + updated_at as "updated_at!: DateTime" + FROM executor_sessions + WHERE id = $1"#, + id + ) + .fetch_optional(pool) + .await + } + + /// Find executor session by execution process ID + pub async fn find_by_execution_process_id( + pool: &SqlitePool, + execution_process_id: Uuid, + ) -> Result, sqlx::Error> { + sqlx::query_as!( + ExecutorSession, + r#"SELECT + id as "id!: Uuid", + task_attempt_id as "task_attempt_id!: Uuid", + execution_process_id as "execution_process_id!: Uuid", + session_id, + prompt, + created_at as "created_at!: DateTime", + updated_at as "updated_at!: DateTime" + FROM executor_sessions + WHERE execution_process_id = $1"#, + execution_process_id + ) + .fetch_optional(pool) + .await + } + + /// Find all executor sessions for a task attempt + pub async fn find_by_task_attempt_id( + pool: &SqlitePool, + task_attempt_id: Uuid, + ) -> Result, sqlx::Error> { + sqlx::query_as!( + ExecutorSession, + r#"SELECT + id as "id!: Uuid", + task_attempt_id as "task_attempt_id!: Uuid", + execution_process_id as "execution_process_id!: Uuid", + session_id, + prompt, + created_at as "created_at!: DateTime", + updated_at as "updated_at!: DateTime" + FROM executor_sessions + WHERE task_attempt_id = $1 + ORDER BY created_at ASC"#, + task_attempt_id + ) + .fetch_all(pool) + .await + } + + /// Create a new executor session + pub async fn create( + pool: &SqlitePool, + data: &CreateExecutorSession, + session_id: Uuid, + ) -> Result { + let now = Utc::now(); + + sqlx::query_as!( + ExecutorSession, + r#"INSERT INTO executor_sessions ( + id, task_attempt_id, execution_process_id, session_id, prompt, + created_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING + id as "id!: Uuid", + task_attempt_id as "task_attempt_id!: Uuid", + execution_process_id as "execution_process_id!: Uuid", + session_id, + prompt, + created_at as "created_at!: DateTime", + updated_at as "updated_at!: DateTime""#, + session_id, + data.task_attempt_id, + data.execution_process_id, + None::, // session_id initially None until parsed from output + data.prompt, + now, // created_at + now // updated_at + ) + .fetch_one(pool) + .await + } + + /// Update executor session with external session ID + pub async fn update_session_id( + pool: &SqlitePool, + execution_process_id: Uuid, + external_session_id: &str, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + r#"UPDATE executor_sessions + SET session_id = $1, updated_at = datetime('now') + WHERE execution_process_id = $2"#, + external_session_id, + execution_process_id + ) + .execute(pool) + .await?; + + Ok(()) + } + + /// Update executor session prompt + pub async fn update_prompt( + pool: &SqlitePool, + id: Uuid, + prompt: &str, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + r#"UPDATE executor_sessions + SET prompt = $1, updated_at = datetime('now') + WHERE id = $2"#, + prompt, + id + ) + .execute(pool) + .await?; + + Ok(()) + } + + /// Delete executor sessions for a task attempt (cleanup) + pub async fn delete_by_task_attempt_id( + pool: &SqlitePool, + task_attempt_id: Uuid, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + "DELETE FROM executor_sessions WHERE task_attempt_id = $1", + task_attempt_id + ) + .execute(pool) + .await?; + + Ok(()) + } +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 03893fb2..c27e934a 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -1,6 +1,7 @@ pub mod api_response; pub mod config; pub mod execution_process; +pub mod executor_session; pub mod project; pub mod task; pub mod task_attempt; diff --git a/backend/src/models/task_attempt.rs b/backend/src/models/task_attempt.rs index 08b1bc83..68e985c4 100644 --- a/backend/src/models/task_attempt.rs +++ b/backend/src/models/task_attempt.rs @@ -82,6 +82,12 @@ pub struct UpdateTaskAttempt { // Currently no updateable fields, but keeping struct for API compatibility } +#[derive(Debug, Deserialize, TS)] +#[ts(export)] +pub struct CreateFollowUpAttempt { + pub prompt: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export)] pub enum DiffChunkType { @@ -376,10 +382,37 @@ impl TaskAttempt { task_id: Uuid, project_id: Uuid, ) -> Result<(), TaskAttemptError> { - use crate::models::project::Project; use crate::models::task::{Task, TaskStatus}; - // Get the task attempt, task, and project + // Load required entities + let (task_attempt, project) = + Self::load_execution_context(pool, attempt_id, project_id).await?; + + // Update task status to indicate execution has started + Task::update_status(pool, task_id, project_id, TaskStatus::InProgress).await?; + + // Determine execution sequence based on project configuration + if Self::should_run_setup_script(&project) { + Self::start_setup_script( + pool, + app_state, + attempt_id, + task_id, + &project, + &task_attempt.worktree_path, + ) + .await + } else { + Self::start_coding_agent(pool, app_state, attempt_id, task_id, project_id).await + } + } + + /// Load the execution context (task attempt and project) with validation + async fn load_execution_context( + pool: &SqlitePool, + attempt_id: Uuid, + project_id: Uuid, + ) -> Result<(TaskAttempt, Project), TaskAttemptError> { let task_attempt = TaskAttempt::find_by_id(pool, attempt_id) .await? .ok_or(TaskAttemptError::TaskNotFound)?; @@ -388,36 +421,149 @@ impl TaskAttempt { .await? .ok_or(TaskAttemptError::ProjectNotFound)?; - // Update task status to InProgress at the start of execution (during setup) - Task::update_status(pool, task_id, project_id, TaskStatus::InProgress).await?; - - // Step 1: Run setup script if it exists - if let Some(setup_script) = &project.setup_script { - if !setup_script.trim().is_empty() { - Self::start_process_execution( - pool, - app_state, - attempt_id, - task_id, - crate::executor::ExecutorType::SetupScript(setup_script.clone()), - "Starting setup script".to_string(), - TaskAttemptStatus::SetupRunning, - crate::models::execution_process::ExecutionProcessType::SetupScript, - &task_attempt.worktree_path, - ) - .await?; - - // Wait for setup script to complete before starting executor - // We'll let the execution monitor handle the completion and then start the executor - return Ok(()); - } - } - - // If no setup script, start executor directly - Self::start_coding_agent(pool, app_state, attempt_id, task_id, project_id).await + Ok((task_attempt, project)) } - /// Unified function to start any type of process execution (setup script or coding agent) + /// Check if setup script should be executed + fn should_run_setup_script(project: &Project) -> bool { + project + .setup_script + .as_ref() + .map(|script| !script.trim().is_empty()) + .unwrap_or(false) + } + + /// Start the setup script execution + async fn start_setup_script( + pool: &SqlitePool, + app_state: &crate::app_state::AppState, + attempt_id: Uuid, + task_id: Uuid, + project: &Project, + worktree_path: &str, + ) -> Result<(), TaskAttemptError> { + let setup_script = project.setup_script.as_ref().unwrap(); + + Self::start_process_execution( + pool, + app_state, + attempt_id, + task_id, + crate::executor::ExecutorType::SetupScript(setup_script.clone()), + "Starting setup script".to_string(), + TaskAttemptStatus::SetupRunning, + crate::models::execution_process::ExecutionProcessType::SetupScript, + worktree_path, + ) + .await + } + + /// Start the coding agent after setup is complete or if no setup is needed + pub async fn start_coding_agent( + pool: &SqlitePool, + app_state: &crate::app_state::AppState, + attempt_id: Uuid, + task_id: Uuid, + _project_id: Uuid, + ) -> Result<(), TaskAttemptError> { + let task_attempt = TaskAttempt::find_by_id(pool, attempt_id) + .await? + .ok_or(TaskAttemptError::TaskNotFound)?; + + let executor_config = Self::resolve_executor_config(&task_attempt.executor); + + Self::start_process_execution( + pool, + app_state, + attempt_id, + task_id, + crate::executor::ExecutorType::CodingAgent(executor_config), + "Starting executor".to_string(), + TaskAttemptStatus::ExecutorRunning, + crate::models::execution_process::ExecutionProcessType::CodingAgent, + &task_attempt.worktree_path, + ) + .await + } + + /// Start a follow-up execution using the same executor type as the first process + pub async fn start_followup_execution( + pool: &SqlitePool, + app_state: &crate::app_state::AppState, + attempt_id: Uuid, + task_id: Uuid, + project_id: Uuid, + prompt: &str, + ) -> Result<(), TaskAttemptError> { + use crate::models::executor_session::ExecutorSession; + + // Get task attempt + let task_attempt = TaskAttempt::find_by_id(pool, attempt_id) + .await? + .ok_or(TaskAttemptError::TaskNotFound)?; + + // Find the most recent coding agent execution process to get the executor type + let execution_processes = + crate::models::execution_process::ExecutionProcess::find_by_task_attempt_id( + pool, attempt_id, + ) + .await?; + let most_recent_coding_agent = execution_processes + .iter() + .rev() // Reverse to get most recent first (since they're ordered by created_at ASC) + .find(|p| { + matches!( + p.process_type, + crate::models::execution_process::ExecutionProcessType::CodingAgent + ) + }) + .ok_or(TaskAttemptError::TaskNotFound)?; // No previous coding agent found + + // Get the executor session to find the session ID + let executor_session = + ExecutorSession::find_by_execution_process_id(pool, most_recent_coding_agent.id) + .await? + .ok_or(TaskAttemptError::TaskNotFound)?; // No session found + + // Determine the executor config from the stored executor_type + let executor_config = match most_recent_coding_agent.executor_type.as_deref() { + Some("claude") => crate::executor::ExecutorConfig::Claude, + Some("amp") => crate::executor::ExecutorConfig::Amp, + Some("echo") => crate::executor::ExecutorConfig::Echo, + _ => return Err(TaskAttemptError::TaskNotFound), // Invalid executor type + }; + + // Create the follow-up executor type + let followup_executor = crate::executor::ExecutorType::FollowUpCodingAgent { + config: executor_config, + session_id: executor_session.session_id.clone(), + prompt: prompt.to_string(), + }; + + Self::start_process_execution( + pool, + app_state, + attempt_id, + task_id, + followup_executor, + "Starting follow-up executor".to_string(), + TaskAttemptStatus::ExecutorRunning, + crate::models::execution_process::ExecutionProcessType::CodingAgent, + &task_attempt.worktree_path, + ) + .await + } + + /// Resolve executor configuration from string name + fn resolve_executor_config(executor_name: &Option) -> crate::executor::ExecutorConfig { + match executor_name.as_ref().map(|s| s.as_str()) { + Some("claude") => crate::executor::ExecutorConfig::Claude, + Some("amp") => crate::executor::ExecutorConfig::Amp, + _ => crate::executor::ExecutorConfig::Echo, // Default for "echo" or None + } + } + + /// Unified function to start any type of process execution async fn start_process_execution( pool: &SqlitePool, app_state: &crate::app_state::AppState, @@ -429,49 +575,182 @@ impl TaskAttempt { process_type: crate::models::execution_process::ExecutionProcessType, worktree_path: &str, ) -> Result<(), TaskAttemptError> { - use crate::executors::SetupScriptExecutor; - use crate::models::execution_process::{CreateExecutionProcess, ExecutionProcess}; - use crate::models::task_attempt_activity::{ - CreateTaskAttemptActivity, TaskAttemptActivity, - }; - - // Create execution process record first (since activity now references it) let process_id = Uuid::new_v4(); - let (command, args) = match &executor_type { + + // Create execution process record + let _execution_process = Self::create_execution_process_record( + pool, + attempt_id, + process_id, + &executor_type, + process_type.clone(), + worktree_path, + ) + .await?; + + // Create executor session for coding agents + if matches!( + process_type, + crate::models::execution_process::ExecutionProcessType::CodingAgent + ) { + Self::create_executor_session_record(pool, attempt_id, task_id, process_id).await?; + } + + // Create activity record + Self::create_activity_record(pool, process_id, activity_status.clone(), &activity_note) + .await?; + + tracing::info!("Starting {} for task attempt {}", activity_note, attempt_id); + + // Execute the process + let child = Self::execute_process( + &executor_type, + pool, + task_id, + attempt_id, + process_id, + worktree_path, + ) + .await?; + + // Register for monitoring + Self::register_for_monitoring(app_state, process_id, attempt_id, &process_type, child) + .await; + + tracing::info!( + "Started execution {} for task attempt {}", + process_id, + attempt_id + ); + Ok(()) + } + + /// Create execution process database record + async fn create_execution_process_record( + pool: &SqlitePool, + attempt_id: Uuid, + process_id: Uuid, + executor_type: &crate::executor::ExecutorType, + process_type: crate::models::execution_process::ExecutionProcessType, + worktree_path: &str, + ) -> Result { + use crate::models::execution_process::{CreateExecutionProcess, ExecutionProcess}; + + let (command, args, executor_type_string) = match executor_type { crate::executor::ExecutorType::SetupScript(_) => ( "bash".to_string(), Some(serde_json::to_string(&["-c", "setup_script"]).unwrap()), + None, // Setup scripts don't have an executor type ), - crate::executor::ExecutorType::CodingAgent(_) => ("executor".to_string(), None), + crate::executor::ExecutorType::CodingAgent(config) => { + let executor_type_str = match config { + crate::executor::ExecutorConfig::Echo => "echo", + crate::executor::ExecutorConfig::Claude => "claude", + crate::executor::ExecutorConfig::Amp => "amp", + }; + ( + "executor".to_string(), + None, + Some(executor_type_str.to_string()), + ) + } + crate::executor::ExecutorType::FollowUpCodingAgent { config, .. } => { + let executor_type_str = match config { + crate::executor::ExecutorConfig::Echo => "echo", + crate::executor::ExecutorConfig::Claude => "claude", + crate::executor::ExecutorConfig::Amp => "amp", + }; + ( + "followup_executor".to_string(), + None, + Some(executor_type_str.to_string()), + ) + } }; let create_process = CreateExecutionProcess { task_attempt_id: attempt_id, - process_type: process_type.clone(), + process_type, + executor_type: executor_type_string, command, args, working_directory: worktree_path.to_string(), }; - let _process = ExecutionProcess::create(pool, &create_process, process_id).await?; + ExecutionProcess::create(pool, &create_process, process_id) + .await + .map_err(TaskAttemptError::from) + } + + /// Create executor session record for coding agents + async fn create_executor_session_record( + pool: &SqlitePool, + attempt_id: Uuid, + task_id: Uuid, + process_id: Uuid, + ) -> Result<(), TaskAttemptError> { + use crate::models::executor_session::{CreateExecutorSession, ExecutorSession}; + + // Get the task to create prompt + let task = Task::find_by_id(pool, task_id) + .await? + .ok_or(TaskAttemptError::TaskNotFound)?; + + let prompt = format!("{}\n\n{}", task.title, task.description.unwrap_or_default()); + + let session_id = Uuid::new_v4(); + let create_session = CreateExecutorSession { + task_attempt_id: attempt_id, + execution_process_id: process_id, + prompt: Some(prompt), + }; + + ExecutorSession::create(pool, &create_session, session_id) + .await + .map(|_| ()) + .map_err(TaskAttemptError::from) + } + + /// Create activity record for process start + async fn create_activity_record( + pool: &SqlitePool, + process_id: Uuid, + activity_status: TaskAttemptStatus, + activity_note: &str, + ) -> Result<(), TaskAttemptError> { + use crate::models::task_attempt_activity::{ + CreateTaskAttemptActivity, TaskAttemptActivity, + }; - // Create activity for process start (after process is created) let activity_id = Uuid::new_v4(); let create_activity = CreateTaskAttemptActivity { execution_process_id: process_id, status: Some(activity_status.clone()), - note: Some(activity_note.clone()), + note: Some(activity_note.to_string()), }; - TaskAttemptActivity::create(pool, &create_activity, activity_id, activity_status.clone()) - .await?; + TaskAttemptActivity::create(pool, &create_activity, activity_id, activity_status) + .await + .map(|_| ()) + .map_err(TaskAttemptError::from) + } - tracing::info!("Starting {} for task attempt {}", activity_note, attempt_id); + /// Execute the process based on type + async fn execute_process( + executor_type: &crate::executor::ExecutorType, + pool: &SqlitePool, + task_id: Uuid, + attempt_id: Uuid, + process_id: Uuid, + worktree_path: &str, + ) -> Result { + use crate::executors::SetupScriptExecutor; - // Create the appropriate executor and spawn the process - let child = match executor_type { + let result = match executor_type { crate::executor::ExecutorType::SetupScript(script) => { - let executor = SetupScriptExecutor { script }; + let executor = SetupScriptExecutor { + script: script.clone(), + }; executor .execute_streaming(pool, task_id, attempt_id, process_id, worktree_path) .await @@ -482,10 +761,57 @@ impl TaskAttempt { .execute_streaming(pool, task_id, attempt_id, process_id, worktree_path) .await } - } - .map_err(|e| TaskAttemptError::Git(git2::Error::from_str(&e.to_string())))?; + crate::executor::ExecutorType::FollowUpCodingAgent { + config, + session_id, + prompt, + } => { + use crate::executors::{AmpFollowupExecutor, ClaudeFollowupExecutor}; - // Add to running executions for monitoring + let executor: Box = match config { + crate::executor::ExecutorConfig::Claude => { + if let Some(sid) = session_id { + Box::new(ClaudeFollowupExecutor { + session_id: sid.clone(), + prompt: prompt.clone(), + }) + } else { + return Err(TaskAttemptError::TaskNotFound); // No session ID for followup + } + } + crate::executor::ExecutorConfig::Amp => { + if let Some(tid) = session_id { + Box::new(AmpFollowupExecutor { + thread_id: tid.clone(), + prompt: prompt.clone(), + }) + } else { + return Err(TaskAttemptError::TaskNotFound); // No thread ID for followup + } + } + crate::executor::ExecutorConfig::Echo => { + // Echo doesn't support followup, use regular echo + config.create_executor() + } + }; + + executor + .execute_streaming(pool, task_id, attempt_id, process_id, worktree_path) + .await + } + }; + + result.map_err(|e| TaskAttemptError::Git(git2::Error::from_str(&e.to_string()))) + } + + /// Register process for monitoring + async fn register_for_monitoring( + app_state: &crate::app_state::AppState, + process_id: Uuid, + attempt_id: Uuid, + process_type: &crate::models::execution_process::ExecutionProcessType, + child: tokio::process::Child, + ) { let execution_type = match process_type { crate::models::execution_process::ExecutionProcessType::SetupScript => { crate::app_state::ExecutionType::SetupScript @@ -508,53 +834,6 @@ impl TaskAttempt { }, ) .await; - - tracing::info!( - "Started execution {} for task attempt {}", - process_id, - attempt_id - ); - - Ok(()) - } - - /// Start the coding agent after setup is complete or if no setup is needed - pub async fn start_coding_agent( - pool: &SqlitePool, - app_state: &crate::app_state::AppState, - attempt_id: Uuid, - task_id: Uuid, - _project_id: Uuid, - ) -> Result<(), TaskAttemptError> { - // Get the task attempt to determine executor config - let task_attempt = TaskAttempt::find_by_id(pool, attempt_id) - .await? - .ok_or(TaskAttemptError::TaskNotFound)?; - - // Determine the executor config - let executor_config = if let Some(executor_name) = &task_attempt.executor { - match executor_name.as_str() { - "echo" => crate::executor::ExecutorConfig::Echo, - "claude" => crate::executor::ExecutorConfig::Claude, - "amp" => crate::executor::ExecutorConfig::Amp, - _ => crate::executor::ExecutorConfig::Echo, // Default fallback - } - } else { - crate::executor::ExecutorConfig::Echo // Default - }; - - Self::start_process_execution( - pool, - app_state, - attempt_id, - task_id, - crate::executor::ExecutorType::CodingAgent(executor_config), - "Starting executor".to_string(), - TaskAttemptStatus::ExecutorRunning, - crate::models::execution_process::ExecutionProcessType::CodingAgent, - &task_attempt.worktree_path, - ) - .await } /// Get the git diff between the base commit and the current committed worktree state diff --git a/backend/src/routes/task_attempts.rs b/backend/src/routes/task_attempts.rs index a6e73c99..60cfbcef 100644 --- a/backend/src/routes/task_attempts.rs +++ b/backend/src/routes/task_attempts.rs @@ -13,7 +13,10 @@ use uuid::Uuid; use crate::models::{ execution_process::ExecutionProcess, task::Task, - task_attempt::{BranchStatus, CreateTaskAttempt, TaskAttempt, TaskAttemptStatus, WorktreeDiff}, + task_attempt::{ + BranchStatus, CreateFollowUpAttempt, CreateTaskAttempt, TaskAttempt, TaskAttemptStatus, + WorktreeDiff, + }, task_attempt_activity::{CreateTaskAttemptActivity, TaskAttemptActivity}, ApiResponse, }; @@ -746,6 +749,53 @@ pub async fn delete_task_attempt_file( } } +pub async fn create_followup_attempt( + Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>, + Extension(pool): Extension, + Extension(app_state): Extension, + Json(payload): Json, +) -> Result>, StatusCode> { + // Verify task attempt exists + if !TaskAttempt::exists_for_task(&pool, attempt_id, task_id, project_id) + .await + .map_err(|e| { + tracing::error!("Failed to check task attempt existence: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })? + { + return Err(StatusCode::NOT_FOUND); + } + + // Start follow-up execution asynchronously + let pool_clone = pool.clone(); + let app_state_clone = app_state.clone(); + let prompt = payload.prompt.clone(); + tokio::spawn(async move { + if let Err(e) = TaskAttempt::start_followup_execution( + &pool_clone, + &app_state_clone, + attempt_id, + task_id, + project_id, + &prompt, + ) + .await + { + tracing::error!( + "Failed to start follow-up execution for task attempt {}: {}", + attempt_id, + e + ); + } + }); + + Ok(ResponseJson(ApiResponse { + success: true, + data: Some("Follow-up execution started successfully".to_string()), + message: Some("Follow-up execution started successfully".to_string()), + })) +} + pub fn task_attempts_router() -> Router { use axum::routing::post; @@ -799,4 +849,8 @@ pub fn task_attempts_router() -> Router { "/projects/:project_id/execution-processes/:process_id", get(get_execution_process), ) + .route( + "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/follow-up", + post(create_followup_attempt), + ) } diff --git a/frontend/src/components/tasks/TaskDetailsPanel.tsx b/frontend/src/components/tasks/TaskDetailsPanel.tsx index 4520a4dc..9e762472 100644 --- a/frontend/src/components/tasks/TaskDetailsPanel.tsx +++ b/frontend/src/components/tasks/TaskDetailsPanel.tsx @@ -12,11 +12,13 @@ import { Edit, Trash2, StopCircle, + Send, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Chip } from "@/components/ui/chip"; +import { Textarea } from "@/components/ui/textarea"; import { ExecutionOutputViewer } from "./ExecutionOutputViewer"; import { EditorSelectionDialog } from "./EditorSelectionDialog"; @@ -146,6 +148,8 @@ export function TaskDetailsPanel({ new Set() ); const [showEditorDialog, setShowEditorDialog] = useState(false); + const [followUpMessage, setFollowUpMessage] = useState(""); + const [isSendingFollowUp, setIsSendingFollowUp] = useState(false); const { config } = useConfig(); // Available executors @@ -185,6 +189,20 @@ export function TaskDetailsPanel({ ); }, [selectedAttempt, attemptActivities, isStopping]); + // Check if follow-up should be enabled + const canSendFollowUp = useMemo(() => { + if (!selectedAttempt || attemptActivities.length === 0 || isAttemptRunning || isSendingFollowUp) { + return false; + } + + // Need at least one completed coding agent execution + const codingAgentActivities = attemptActivities.filter( + (activity) => activity.status === "executorcomplete" + ); + + return codingAgentActivities.length > 0; + }, [selectedAttempt, attemptActivities, isAttemptRunning, isSendingFollowUp]); + // Polling for updates when attempt is running useEffect(() => { if (!isAttemptRunning || !task) return; @@ -410,6 +428,39 @@ export function TaskDetailsPanel({ }); }; + const handleSendFollowUp = async () => { + if (!task || !selectedAttempt || !followUpMessage.trim()) return; + + try { + setIsSendingFollowUp(true); + const response = await makeRequest( + `/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/follow-up`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + prompt: followUpMessage.trim(), + }), + } + ); + + if (response.ok) { + // Clear the message + setFollowUpMessage(""); + // Refresh activities to show the new follow-up execution + fetchAttemptActivities(selectedAttempt.id); + } else { + console.error("Failed to send follow-up:", await response.text()); + } + } catch (err) { + console.error("Failed to send follow-up:", err); + } finally { + setIsSendingFollowUp(false); + } + }; + if (!task) return null; return ( @@ -730,13 +781,13 @@ export function TaskDetailsPanel({ ] && (
- {/* Footer */} - {/*
-
- -
-