diff --git a/Cargo.lock b/Cargo.lock index 2ac55177f6a..53e5cb595aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8925,6 +8925,7 @@ dependencies = [ "tokio", "tokio-tungstenite 0.26.2", "tokio-util", + "tower", "tower-http 0.5.2", "tracing", "tracing-subscriber", diff --git a/crates/db/.sqlx/query-2b859252c7ffc02915f0ffe74345e29ab9039cbdb4fd2144601b152b98ac3fd0.json b/crates/db/.sqlx/query-2b859252c7ffc02915f0ffe74345e29ab9039cbdb4fd2144601b152b98ac3fd0.json new file mode 100644 index 00000000000..b9853d56252 --- /dev/null +++ b/crates/db/.sqlx/query-2b859252c7ffc02915f0ffe74345e29ab9039cbdb4fd2144601b152b98ac3fd0.json @@ -0,0 +1,110 @@ +{ + "db_name": "SQLite", + "query": "SELECT\n w.id AS \"id!: Uuid\",\n w.task_id AS \"task_id: Uuid\",\n w.container_ref,\n w.branch,\n w.setup_completed_at AS \"setup_completed_at: DateTime\",\n w.created_at AS \"created_at!: DateTime\",\n w.updated_at AS \"updated_at!: DateTime\",\n w.archived AS \"archived!: bool\",\n w.archived_at AS \"archived_at: DateTime\",\n w.pinned AS \"pinned!: bool\",\n w.name,\n w.worktree_deleted AS \"worktree_deleted!: bool\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n LIMIT 1\n ) OR EXISTS (\n -- CLI-mode tmux claude actively producing output\n SELECT 1\n FROM workspace_cli_activity ca\n WHERE ca.workspace_id = w.id\n AND ca.state = 'running'\n ) THEN 1 ELSE 0 END AS \"is_running!: i64\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"is_executor_running!: i64\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason = 'codingagent'\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"is_coding_agent_running!: i64\",\n\n CASE WHEN (\n SELECT ep.status\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n ORDER BY ep.created_at DESC\n LIMIT 1\n ) IN ('failed','killed') THEN 1 ELSE 0 END AS \"is_errored!: i64\"\n\n FROM workspaces w\n WHERE w.id = $1", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "task_id: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "container_ref", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "branch", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "setup_completed_at: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "archived!: bool", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "archived_at: DateTime", + "ordinal": 8, + "type_info": "Datetime" + }, + { + "name": "pinned!: bool", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "worktree_deleted!: bool", + "ordinal": 11, + "type_info": "Bool" + }, + { + "name": "is_running!: i64", + "ordinal": 12, + "type_info": "Null" + }, + { + "name": "is_executor_running!: i64", + "ordinal": 13, + "type_info": "Null" + }, + { + "name": "is_coding_agent_running!: i64", + "ordinal": 14, + "type_info": "Null" + }, + { + "name": "is_errored!: i64", + "ordinal": 15, + "type_info": "Null" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + true, + true, + false, + true, + false, + false, + false, + true, + false, + true, + false, + null, + null, + null, + null + ] + }, + "hash": "2b859252c7ffc02915f0ffe74345e29ab9039cbdb4fd2144601b152b98ac3fd0" +} diff --git a/crates/db/.sqlx/query-42c452ed4370a6c3403b7e16b25e75fe336537063a13e8ce39a16a1f6953da69.json b/crates/db/.sqlx/query-42c452ed4370a6c3403b7e16b25e75fe336537063a13e8ce39a16a1f6953da69.json new file mode 100644 index 00000000000..e0f07b37b39 --- /dev/null +++ b/crates/db/.sqlx/query-42c452ed4370a6c3403b7e16b25e75fe336537063a13e8ce39a16a1f6953da69.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE workspaces SET\n archived = $1,\n archived_at = CASE\n WHEN $1 = TRUE AND archived = FALSE THEN datetime('now', 'subsec')\n WHEN $1 = FALSE THEN NULL\n ELSE archived_at\n END,\n updated_at = datetime('now', 'subsec')\n WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "42c452ed4370a6c3403b7e16b25e75fe336537063a13e8ce39a16a1f6953da69" +} diff --git a/crates/db/.sqlx/query-4d86dcbf754f971ff3acffe9e85b5c2f455e77c40c624e09736be9480238110b.json b/crates/db/.sqlx/query-4d86dcbf754f971ff3acffe9e85b5c2f455e77c40c624e09736be9480238110b.json deleted file mode 100644 index 827c9f25fb3..00000000000 --- a/crates/db/.sqlx/query-4d86dcbf754f971ff3acffe9e85b5c2f455e77c40c624e09736be9480238110b.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE workspaces SET archived = $1, updated_at = datetime('now', 'subsec') WHERE id = $2", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "4d86dcbf754f971ff3acffe9e85b5c2f455e77c40c624e09736be9480238110b" -} diff --git a/crates/db/.sqlx/query-57d6335c98deb608cf961836f73a67163af6484f91954c0577aea1cc2fddac4f.json b/crates/db/.sqlx/query-57d6335c98deb608cf961836f73a67163af6484f91954c0577aea1cc2fddac4f.json deleted file mode 100644 index d016685e4e8..00000000000 --- a/crates/db/.sqlx/query-57d6335c98deb608cf961836f73a67163af6484f91954c0577aea1cc2fddac4f.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE workspaces SET\n archived = COALESCE($1, archived),\n pinned = COALESCE($2, pinned),\n name = CASE WHEN $3 THEN $4 ELSE name END,\n updated_at = datetime('now', 'subsec')\n WHERE id = $5", - "describe": { - "columns": [], - "parameters": { - "Right": 5 - }, - "nullable": [] - }, - "hash": "57d6335c98deb608cf961836f73a67163af6484f91954c0577aea1cc2fddac4f" -} diff --git a/crates/db/.sqlx/query-64f31710ab7ba14047f31cce44ad36c60a53624f9bcb03a5eaff5d61ca8cc9cf.json b/crates/db/.sqlx/query-57f519d7c860951c3842b2526d9c40417843e0ee975b19864f78fdfae6be03cc.json similarity index 77% rename from crates/db/.sqlx/query-64f31710ab7ba14047f31cce44ad36c60a53624f9bcb03a5eaff5d61ca8cc9cf.json rename to crates/db/.sqlx/query-57f519d7c860951c3842b2526d9c40417843e0ee975b19864f78fdfae6be03cc.json index da3ce7ad637..6df69dc0bed 100644 --- a/crates/db/.sqlx/query-64f31710ab7ba14047f31cce44ad36c60a53624f9bcb03a5eaff5d61ca8cc9cf.json +++ b/crates/db/.sqlx/query-57f519d7c860951c3842b2526d9c40417843e0ee975b19864f78fdfae6be03cc.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id: Uuid\",\n container_ref,\n branch,\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\",\n archived AS \"archived!: bool\",\n pinned AS \"pinned!: bool\",\n name,\n worktree_deleted AS \"worktree_deleted!: bool\"\n FROM workspaces\n ORDER BY created_at DESC", + "query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id: Uuid\",\n container_ref,\n branch,\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\",\n archived AS \"archived!: bool\",\n archived_at AS \"archived_at: DateTime\",\n pinned AS \"pinned!: bool\",\n name,\n worktree_deleted AS \"worktree_deleted!: bool\"\n FROM workspaces\n ORDER BY created_at DESC", "describe": { "columns": [ { @@ -44,18 +44,23 @@ "type_info": "Integer" }, { - "name": "pinned!: bool", + "name": "archived_at: DateTime", "ordinal": 8, + "type_info": "Datetime" + }, + { + "name": "pinned!: bool", + "ordinal": 9, "type_info": "Integer" }, { "name": "name", - "ordinal": 9, + "ordinal": 10, "type_info": "Text" }, { "name": "worktree_deleted!: bool", - "ordinal": 10, + "ordinal": 11, "type_info": "Bool" } ], @@ -71,10 +76,11 @@ false, false, false, + true, false, true, false ] }, - "hash": "64f31710ab7ba14047f31cce44ad36c60a53624f9bcb03a5eaff5d61ca8cc9cf" + "hash": "57f519d7c860951c3842b2526d9c40417843e0ee975b19864f78fdfae6be03cc" } diff --git a/crates/db/.sqlx/query-6a86ee7ff48b8609459acb190c6a522fab0156fac78a2216851e6e70eb68352a.json b/crates/db/.sqlx/query-6a86ee7ff48b8609459acb190c6a522fab0156fac78a2216851e6e70eb68352a.json new file mode 100644 index 00000000000..6f6aa1711ae --- /dev/null +++ b/crates/db/.sqlx/query-6a86ee7ff48b8609459acb190c6a522fab0156fac78a2216851e6e70eb68352a.json @@ -0,0 +1,110 @@ +{ + "db_name": "SQLite", + "query": "SELECT\n w.id AS \"id!: Uuid\",\n w.task_id AS \"task_id: Uuid\",\n w.container_ref,\n w.branch,\n w.setup_completed_at AS \"setup_completed_at: DateTime\",\n w.created_at AS \"created_at!: DateTime\",\n w.updated_at AS \"updated_at!: DateTime\",\n w.archived AS \"archived!: bool\",\n w.archived_at AS \"archived_at: DateTime\",\n w.pinned AS \"pinned!: bool\",\n w.name,\n w.worktree_deleted AS \"worktree_deleted!: bool\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n LIMIT 1\n ) OR EXISTS (\n -- CLI-mode tmux claude actively producing output\n SELECT 1\n FROM workspace_cli_activity ca\n WHERE ca.workspace_id = w.id\n AND ca.state = 'running'\n ) THEN 1 ELSE 0 END AS \"is_running!: i64\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"is_executor_running!: i64\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason = 'codingagent'\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"is_coding_agent_running!: i64\",\n\n CASE WHEN (\n SELECT ep.status\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n ORDER BY ep.created_at DESC\n LIMIT 1\n ) IN ('failed','killed') THEN 1 ELSE 0 END AS \"is_errored!: i64\"\n\n FROM workspaces w\n ORDER BY w.updated_at DESC", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "task_id: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "container_ref", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "branch", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "setup_completed_at: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "archived!: bool", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "archived_at: DateTime", + "ordinal": 8, + "type_info": "Datetime" + }, + { + "name": "pinned!: bool", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "worktree_deleted!: bool", + "ordinal": 11, + "type_info": "Bool" + }, + { + "name": "is_running!: i64", + "ordinal": 12, + "type_info": "Integer" + }, + { + "name": "is_executor_running!: i64", + "ordinal": 13, + "type_info": "Integer" + }, + { + "name": "is_coding_agent_running!: i64", + "ordinal": 14, + "type_info": "Integer" + }, + { + "name": "is_errored!: i64", + "ordinal": 15, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true, + true, + true, + false, + true, + false, + false, + false, + true, + false, + true, + false, + false, + false, + false, + false + ] + }, + "hash": "6a86ee7ff48b8609459acb190c6a522fab0156fac78a2216851e6e70eb68352a" +} diff --git a/crates/db/.sqlx/query-766fa107de23b7e6c579223b083d916e252d422e2908c27f6718fcbd851de2c1.json b/crates/db/.sqlx/query-85475083809654f6a916ce5157349baf195ff5e112b2e1d83063b51043060a27.json similarity index 79% rename from crates/db/.sqlx/query-766fa107de23b7e6c579223b083d916e252d422e2908c27f6718fcbd851de2c1.json rename to crates/db/.sqlx/query-85475083809654f6a916ce5157349baf195ff5e112b2e1d83063b51043060a27.json index d77d242dd46..55643884902 100644 --- a/crates/db/.sqlx/query-766fa107de23b7e6c579223b083d916e252d422e2908c27f6718fcbd851de2c1.json +++ b/crates/db/.sqlx/query-85475083809654f6a916ce5157349baf195ff5e112b2e1d83063b51043060a27.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id: Uuid\",\n container_ref,\n branch,\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\",\n archived AS \"archived!: bool\",\n pinned AS \"pinned!: bool\",\n name,\n worktree_deleted AS \"worktree_deleted!: bool\"\n FROM workspaces\n WHERE id = $1", + "query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id: Uuid\",\n container_ref,\n branch,\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\",\n archived AS \"archived!: bool\",\n archived_at AS \"archived_at: DateTime\",\n pinned AS \"pinned!: bool\",\n name,\n worktree_deleted AS \"worktree_deleted!: bool\"\n FROM workspaces\n WHERE id = $1", "describe": { "columns": [ { @@ -44,18 +44,23 @@ "type_info": "Integer" }, { - "name": "pinned!: bool", + "name": "archived_at: DateTime", "ordinal": 8, + "type_info": "Datetime" + }, + { + "name": "pinned!: bool", + "ordinal": 9, "type_info": "Integer" }, { "name": "name", - "ordinal": 9, + "ordinal": 10, "type_info": "Text" }, { "name": "worktree_deleted!: bool", - "ordinal": 10, + "ordinal": 11, "type_info": "Bool" } ], @@ -71,10 +76,11 @@ false, false, false, + true, false, true, false ] }, - "hash": "766fa107de23b7e6c579223b083d916e252d422e2908c27f6718fcbd851de2c1" + "hash": "85475083809654f6a916ce5157349baf195ff5e112b2e1d83063b51043060a27" } diff --git a/crates/db/.sqlx/query-a06582428c5e925cfc9e56656e0ff9759a0265755a93ce1f602a6bcf71d4f586.json b/crates/db/.sqlx/query-a06582428c5e925cfc9e56656e0ff9759a0265755a93ce1f602a6bcf71d4f586.json deleted file mode 100644 index 71b0c214874..00000000000 --- a/crates/db/.sqlx/query-a06582428c5e925cfc9e56656e0ff9759a0265755a93ce1f602a6bcf71d4f586.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT\n w.id AS \"id!: Uuid\",\n w.task_id AS \"task_id: Uuid\",\n w.container_ref,\n w.branch,\n w.setup_completed_at AS \"setup_completed_at: DateTime\",\n w.created_at AS \"created_at!: DateTime\",\n w.updated_at AS \"updated_at!: DateTime\",\n w.archived AS \"archived!: bool\",\n w.pinned AS \"pinned!: bool\",\n w.name,\n w.worktree_deleted AS \"worktree_deleted!: bool\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n LIMIT 1\n ) OR EXISTS (\n -- CLI-mode tmux claude actively producing output\n SELECT 1\n FROM workspace_cli_activity ca\n WHERE ca.workspace_id = w.id\n AND ca.state = 'running'\n ) THEN 1 ELSE 0 END AS \"is_running!: i64\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"is_executor_running!: i64\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason = 'codingagent'\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"is_coding_agent_running!: i64\",\n\n CASE WHEN (\n SELECT ep.status\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n ORDER BY ep.created_at DESC\n LIMIT 1\n ) IN ('failed','killed') THEN 1 ELSE 0 END AS \"is_errored!: i64\"\n\n FROM workspaces w\n ORDER BY w.updated_at DESC", - "describe": { - "columns": [ - { - "name": "id!: Uuid", - "ordinal": 0, - "type_info": "Blob" - }, - { - "name": "task_id: Uuid", - "ordinal": 1, - "type_info": "Blob" - }, - { - "name": "container_ref", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "branch", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "setup_completed_at: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "created_at!: DateTime", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "updated_at!: DateTime", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "archived!: bool", - "ordinal": 7, - "type_info": "Integer" - }, - { - "name": "pinned!: bool", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "name", - "ordinal": 9, - "type_info": "Text" - }, - { - "name": "worktree_deleted!: bool", - "ordinal": 10, - "type_info": "Bool" - }, - { - "name": "is_running!: i64", - "ordinal": 11, - "type_info": "Integer" - }, - { - "name": "is_executor_running!: i64", - "ordinal": 12, - "type_info": "Integer" - }, - { - "name": "is_coding_agent_running!: i64", - "ordinal": 13, - "type_info": "Integer" - }, - { - "name": "is_errored!: i64", - "ordinal": 14, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - true, - true, - true, - false, - true, - false, - false, - false, - false, - true, - false, - false, - false, - false, - false - ] - }, - "hash": "a06582428c5e925cfc9e56656e0ff9759a0265755a93ce1f602a6bcf71d4f586" -} diff --git a/crates/db/.sqlx/query-f2dbb49b2f839e84a46fdd865d9982b758160517b93bc92d8e12060426daa05d.json b/crates/db/.sqlx/query-a5d6e4601f9a48529036b544140bf1206ecafdc960b3147613ff8c68e988921f.json similarity index 79% rename from crates/db/.sqlx/query-f2dbb49b2f839e84a46fdd865d9982b758160517b93bc92d8e12060426daa05d.json rename to crates/db/.sqlx/query-a5d6e4601f9a48529036b544140bf1206ecafdc960b3147613ff8c68e988921f.json index 0d177a43228..684210d7203 100644 --- a/crates/db/.sqlx/query-f2dbb49b2f839e84a46fdd865d9982b758160517b93bc92d8e12060426daa05d.json +++ b/crates/db/.sqlx/query-a5d6e4601f9a48529036b544140bf1206ecafdc960b3147613ff8c68e988921f.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id: Uuid\",\n container_ref,\n branch,\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\",\n archived AS \"archived!: bool\",\n pinned AS \"pinned!: bool\",\n name,\n worktree_deleted AS \"worktree_deleted!: bool\"\n FROM workspaces\n WHERE rowid = $1", + "query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id: Uuid\",\n container_ref,\n branch,\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\",\n archived AS \"archived!: bool\",\n archived_at AS \"archived_at: DateTime\",\n pinned AS \"pinned!: bool\",\n name,\n worktree_deleted AS \"worktree_deleted!: bool\"\n FROM workspaces\n WHERE rowid = $1", "describe": { "columns": [ { @@ -44,18 +44,23 @@ "type_info": "Integer" }, { - "name": "pinned!: bool", + "name": "archived_at: DateTime", "ordinal": 8, + "type_info": "Datetime" + }, + { + "name": "pinned!: bool", + "ordinal": 9, "type_info": "Integer" }, { "name": "name", - "ordinal": 9, + "ordinal": 10, "type_info": "Text" }, { "name": "worktree_deleted!: bool", - "ordinal": 10, + "ordinal": 11, "type_info": "Bool" } ], @@ -71,10 +76,11 @@ false, false, false, + true, false, true, false ] }, - "hash": "f2dbb49b2f839e84a46fdd865d9982b758160517b93bc92d8e12060426daa05d" + "hash": "a5d6e4601f9a48529036b544140bf1206ecafdc960b3147613ff8c68e988921f" } diff --git a/crates/db/.sqlx/query-7410e8128e63af1c3127e833accee637e65f7efcd9111ecb891587294042129c.json b/crates/db/.sqlx/query-c0f612c3b7584e9c30b83ee9ff662cadc07ad23b839f983cf82ed6df5fc20e3b.json similarity index 82% rename from crates/db/.sqlx/query-7410e8128e63af1c3127e833accee637e65f7efcd9111ecb891587294042129c.json rename to crates/db/.sqlx/query-c0f612c3b7584e9c30b83ee9ff662cadc07ad23b839f983cf82ed6df5fc20e3b.json index de5161171ac..529194aa4d7 100644 --- a/crates/db/.sqlx/query-7410e8128e63af1c3127e833accee637e65f7efcd9111ecb891587294042129c.json +++ b/crates/db/.sqlx/query-c0f612c3b7584e9c30b83ee9ff662cadc07ad23b839f983cf82ed6df5fc20e3b.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "INSERT INTO workspaces (id, task_id, container_ref, branch, setup_completed_at, name)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id as \"id!: Uuid\", task_id as \"task_id: Uuid\", container_ref, branch, setup_completed_at as \"setup_completed_at: DateTime\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\", archived as \"archived!: bool\", pinned as \"pinned!: bool\", name, worktree_deleted as \"worktree_deleted!: bool\"", + "query": "INSERT INTO workspaces (id, task_id, container_ref, branch, setup_completed_at, name)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id as \"id!: Uuid\", task_id as \"task_id: Uuid\", container_ref, branch, setup_completed_at as \"setup_completed_at: DateTime\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\", archived as \"archived!: bool\", archived_at as \"archived_at: DateTime\", pinned as \"pinned!: bool\", name, worktree_deleted as \"worktree_deleted!: bool\"", "describe": { "columns": [ { @@ -44,18 +44,23 @@ "type_info": "Integer" }, { - "name": "pinned!: bool", + "name": "archived_at: DateTime", "ordinal": 8, + "type_info": "Datetime" + }, + { + "name": "pinned!: bool", + "ordinal": 9, "type_info": "Integer" }, { "name": "name", - "ordinal": 9, + "ordinal": 10, "type_info": "Text" }, { "name": "worktree_deleted!: bool", - "ordinal": 10, + "ordinal": 11, "type_info": "Bool" } ], @@ -71,10 +76,11 @@ false, false, false, + true, false, true, false ] }, - "hash": "7410e8128e63af1c3127e833accee637e65f7efcd9111ecb891587294042129c" + "hash": "c0f612c3b7584e9c30b83ee9ff662cadc07ad23b839f983cf82ed6df5fc20e3b" } diff --git a/crates/db/.sqlx/query-d0eafe01b623b10aae3fb09a3c5f993acba8bef194cda58722854394e84d39bb.json b/crates/db/.sqlx/query-d0eafe01b623b10aae3fb09a3c5f993acba8bef194cda58722854394e84d39bb.json new file mode 100644 index 00000000000..f63a1c48874 --- /dev/null +++ b/crates/db/.sqlx/query-d0eafe01b623b10aae3fb09a3c5f993acba8bef194cda58722854394e84d39bb.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE workspaces SET\n archived = COALESCE($1, archived),\n archived_at = CASE\n WHEN $1 = TRUE AND archived = FALSE THEN datetime('now', 'subsec')\n WHEN $1 = FALSE THEN NULL\n ELSE archived_at\n END,\n pinned = COALESCE($2, pinned),\n name = CASE WHEN $3 THEN $4 ELSE name END,\n updated_at = datetime('now', 'subsec')\n WHERE id = $5", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "d0eafe01b623b10aae3fb09a3c5f993acba8bef194cda58722854394e84d39bb" +} diff --git a/crates/db/.sqlx/query-e34ea49c71c3bf57f6e5ec312b6dde3df496e13a80067ecb9a58996018ab9f12.json b/crates/db/.sqlx/query-e34ea49c71c3bf57f6e5ec312b6dde3df496e13a80067ecb9a58996018ab9f12.json deleted file mode 100644 index 579a9a76440..00000000000 --- a/crates/db/.sqlx/query-e34ea49c71c3bf57f6e5ec312b6dde3df496e13a80067ecb9a58996018ab9f12.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT\n w.id AS \"id!: Uuid\",\n w.task_id AS \"task_id: Uuid\",\n w.container_ref,\n w.branch,\n w.setup_completed_at AS \"setup_completed_at: DateTime\",\n w.created_at AS \"created_at!: DateTime\",\n w.updated_at AS \"updated_at!: DateTime\",\n w.archived AS \"archived!: bool\",\n w.pinned AS \"pinned!: bool\",\n w.name,\n w.worktree_deleted AS \"worktree_deleted!: bool\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n LIMIT 1\n ) OR EXISTS (\n -- CLI-mode tmux claude actively producing output\n SELECT 1\n FROM workspace_cli_activity ca\n WHERE ca.workspace_id = w.id\n AND ca.state = 'running'\n ) THEN 1 ELSE 0 END AS \"is_running!: i64\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"is_executor_running!: i64\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason = 'codingagent'\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"is_coding_agent_running!: i64\",\n\n CASE WHEN (\n SELECT ep.status\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n ORDER BY ep.created_at DESC\n LIMIT 1\n ) IN ('failed','killed') THEN 1 ELSE 0 END AS \"is_errored!: i64\"\n\n FROM workspaces w\n WHERE w.id = $1", - "describe": { - "columns": [ - { - "name": "id!: Uuid", - "ordinal": 0, - "type_info": "Blob" - }, - { - "name": "task_id: Uuid", - "ordinal": 1, - "type_info": "Blob" - }, - { - "name": "container_ref", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "branch", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "setup_completed_at: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "created_at!: DateTime", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "updated_at!: DateTime", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "archived!: bool", - "ordinal": 7, - "type_info": "Integer" - }, - { - "name": "pinned!: bool", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "name", - "ordinal": 9, - "type_info": "Text" - }, - { - "name": "worktree_deleted!: bool", - "ordinal": 10, - "type_info": "Bool" - }, - { - "name": "is_running!: i64", - "ordinal": 11, - "type_info": "Null" - }, - { - "name": "is_executor_running!: i64", - "ordinal": 12, - "type_info": "Null" - }, - { - "name": "is_coding_agent_running!: i64", - "ordinal": 13, - "type_info": "Null" - }, - { - "name": "is_errored!: i64", - "ordinal": 14, - "type_info": "Null" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - true, - true, - true, - false, - true, - false, - false, - false, - false, - true, - false, - null, - null, - null, - null - ] - }, - "hash": "e34ea49c71c3bf57f6e5ec312b6dde3df496e13a80067ecb9a58996018ab9f12" -} diff --git a/crates/db/.sqlx/query-91810eeed4804827717a182ad1b61c641648e2659100f43ef9504fc60e5d244e.json b/crates/db/.sqlx/query-f56e9e521125dfbd84815ebefebfc30f2d0fa7b607c447cb0a8789a9c5442854.json similarity index 52% rename from crates/db/.sqlx/query-91810eeed4804827717a182ad1b61c641648e2659100f43ef9504fc60e5d244e.json rename to crates/db/.sqlx/query-f56e9e521125dfbd84815ebefebfc30f2d0fa7b607c447cb0a8789a9c5442854.json index 1008829ef9b..f927cc9b4e8 100644 --- a/crates/db/.sqlx/query-91810eeed4804827717a182ad1b61c641648e2659100f43ef9504fc60e5d244e.json +++ b/crates/db/.sqlx/query-f56e9e521125dfbd84815ebefebfc30f2d0fa7b607c447cb0a8789a9c5442854.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n w.id as \"id!: Uuid\",\n w.task_id as \"task_id: Uuid\",\n w.container_ref,\n w.branch as \"branch!\",\n w.setup_completed_at as \"setup_completed_at: DateTime\",\n w.created_at as \"created_at!: DateTime\",\n w.updated_at as \"updated_at!: DateTime\",\n w.archived as \"archived!: bool\",\n w.pinned as \"pinned!: bool\",\n w.name,\n w.worktree_deleted as \"worktree_deleted!: bool\"\n FROM workspaces w\n LEFT JOIN sessions s ON w.id = s.workspace_id\n LEFT JOIN execution_processes ep ON s.id = ep.session_id AND ep.completed_at IS NOT NULL\n WHERE w.container_ref IS NOT NULL\n AND w.worktree_deleted = FALSE\n AND w.id NOT IN (\n SELECT DISTINCT s2.workspace_id\n FROM sessions s2\n JOIN execution_processes ep2 ON s2.id = ep2.session_id\n WHERE ep2.completed_at IS NULL\n )\n GROUP BY w.id, w.container_ref, w.updated_at\n HAVING datetime('now', 'localtime',\n CASE\n WHEN w.archived = 1\n THEN '-1 hours'\n ELSE '-72 hours'\n END\n ) > datetime(\n MAX(\n max(\n datetime(w.updated_at),\n datetime(ep.completed_at)\n )\n )\n )\n ORDER BY MAX(\n CASE\n WHEN ep.completed_at IS NOT NULL THEN ep.completed_at\n ELSE w.updated_at\n END\n ) ASC\n ", + "query": "\n SELECT\n w.id as \"id!: Uuid\",\n w.task_id as \"task_id: Uuid\",\n w.container_ref,\n w.branch as \"branch!\",\n w.setup_completed_at as \"setup_completed_at: DateTime\",\n w.created_at as \"created_at!: DateTime\",\n w.updated_at as \"updated_at!: DateTime\",\n w.archived as \"archived!: bool\",\n w.archived_at as \"archived_at: DateTime\",\n w.pinned as \"pinned!: bool\",\n w.name,\n w.worktree_deleted as \"worktree_deleted!: bool\"\n FROM workspaces w\n LEFT JOIN sessions s ON w.id = s.workspace_id\n LEFT JOIN execution_processes ep ON s.id = ep.session_id AND ep.completed_at IS NOT NULL\n WHERE w.container_ref IS NOT NULL\n AND w.worktree_deleted = FALSE\n AND w.id NOT IN (\n SELECT DISTINCT s2.workspace_id\n FROM sessions s2\n JOIN execution_processes ep2 ON s2.id = ep2.session_id\n WHERE ep2.completed_at IS NULL\n )\n GROUP BY w.id, w.container_ref, w.updated_at\n HAVING datetime('now', 'localtime',\n CASE\n WHEN w.archived = 1\n THEN '-1 hours'\n ELSE '-72 hours'\n END\n ) > datetime(\n MAX(\n max(\n datetime(w.updated_at),\n datetime(ep.completed_at)\n )\n )\n )\n ORDER BY MAX(\n CASE\n WHEN ep.completed_at IS NOT NULL THEN ep.completed_at\n ELSE w.updated_at\n END\n ) ASC\n ", "describe": { "columns": [ { @@ -44,18 +44,23 @@ "type_info": "Integer" }, { - "name": "pinned!: bool", + "name": "archived_at: DateTime", "ordinal": 8, + "type_info": "Datetime" + }, + { + "name": "pinned!: bool", + "ordinal": 9, "type_info": "Integer" }, { "name": "name", - "ordinal": 9, + "ordinal": 10, "type_info": "Text" }, { "name": "worktree_deleted!: bool", - "ordinal": 10, + "ordinal": 11, "type_info": "Bool" } ], @@ -73,8 +78,9 @@ true, true, true, + true, true ] }, - "hash": "91810eeed4804827717a182ad1b61c641648e2659100f43ef9504fc60e5d244e" + "hash": "f56e9e521125dfbd84815ebefebfc30f2d0fa7b607c447cb0a8789a9c5442854" } diff --git a/crates/db/migrations/20260721103000_add_workspace_archived_at.sql b/crates/db/migrations/20260721103000_add_workspace_archived_at.sql new file mode 100644 index 00000000000..5c11c0eb6f0 --- /dev/null +++ b/crates/db/migrations/20260721103000_add_workspace_archived_at.sql @@ -0,0 +1,8 @@ +ALTER TABLE workspaces ADD COLUMN archived_at DATETIME; + +-- Existing archived workspaces predate an explicit archive timestamp. `updated_at` +-- is the best available one-time proxy because every workspace write, including +-- archive toggles, updates it; for untouched archived rows it is the archive time. +UPDATE workspaces +SET archived_at = updated_at +WHERE archived = TRUE; diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index 2477cbdd283..bc5b090e36f 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -148,3 +148,67 @@ impl DBService { Ok(pool) } } + +#[cfg(test)] +mod tests { + use sqlx::sqlite::SqlitePoolOptions; + use uuid::Uuid; + + #[tokio::test] + async fn archived_at_migration_backfills_preexisting_archived_rows() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::query( + "CREATE TABLE workspaces ( + id BLOB PRIMARY KEY, + updated_at TEXT NOT NULL, + archived INTEGER NOT NULL + )", + ) + .execute(&pool) + .await + .unwrap(); + + let archived_id = Uuid::new_v4(); + let active_id = Uuid::new_v4(); + let archived_updated_at = "2026-07-20 12:34:56.789"; + sqlx::query("INSERT INTO workspaces (id, updated_at, archived) VALUES (?, ?, TRUE)") + .bind(archived_id) + .bind(archived_updated_at) + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO workspaces (id, updated_at, archived) VALUES (?, ?, FALSE)") + .bind(active_id) + .bind("2026-07-21 01:02:03.456") + .execute(&pool) + .await + .unwrap(); + + sqlx::raw_sql(include_str!( + "../migrations/20260721103000_add_workspace_archived_at.sql" + )) + .execute(&pool) + .await + .unwrap(); + + let archived_at: Option = + sqlx::query_scalar("SELECT archived_at FROM workspaces WHERE id = ?") + .bind(archived_id) + .fetch_one(&pool) + .await + .unwrap(); + let active_archived_at: Option = + sqlx::query_scalar("SELECT archived_at FROM workspaces WHERE id = ?") + .bind(active_id) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(archived_at.as_deref(), Some(archived_updated_at)); + assert_eq!(active_archived_at, None); + } +} diff --git a/crates/db/src/models/archive_bucket.rs b/crates/db/src/models/archive_bucket.rs new file mode 100644 index 00000000000..57c0d27b155 --- /dev/null +++ b/crates/db/src/models/archive_bucket.rs @@ -0,0 +1,104 @@ +use chrono::Duration; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// Non-overlapping age ranges for archived workspaces. +/// +/// Keep this half-open boundary table in sync with +/// `packages/ui/src/lib/archiveBuckets.ts`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum ArchiveBucket { + Today, + OneToThreeDays, + ThreeToSevenDays, + SevenToFifteenDays, + FifteenToThirtyDays, + OlderThanThirtyDays, +} + +impl ArchiveBucket { + pub fn from_age(age: Duration) -> Self { + // `num_days` floors positive durations to complete days. Clamp clock + // skew/future timestamps to today instead of producing a negative age. + match age.num_days().max(0) { + 0 => Self::Today, + 1..=2 => Self::OneToThreeDays, + 3..=6 => Self::ThreeToSevenDays, + 7..=14 => Self::SevenToFifteenDays, + 15..=29 => Self::FifteenToThirtyDays, + _ => Self::OlderThanThirtyDays, + } + } +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + + use super::ArchiveBucket; + + #[test] + fn from_age_pins_every_bucket_boundary() { + let millisecond = Duration::milliseconds(1); + let cases = [ + (Duration::zero(), ArchiveBucket::Today), + (millisecond, ArchiveBucket::Today), + (Duration::days(1) - millisecond, ArchiveBucket::Today), + (Duration::days(1), ArchiveBucket::OneToThreeDays), + ( + Duration::days(1) + millisecond, + ArchiveBucket::OneToThreeDays, + ), + ( + Duration::days(3) - millisecond, + ArchiveBucket::OneToThreeDays, + ), + (Duration::days(3), ArchiveBucket::ThreeToSevenDays), + ( + Duration::days(3) + millisecond, + ArchiveBucket::ThreeToSevenDays, + ), + ( + Duration::days(7) - millisecond, + ArchiveBucket::ThreeToSevenDays, + ), + (Duration::days(7), ArchiveBucket::SevenToFifteenDays), + ( + Duration::days(7) + millisecond, + ArchiveBucket::SevenToFifteenDays, + ), + ( + Duration::days(15) - millisecond, + ArchiveBucket::SevenToFifteenDays, + ), + (Duration::days(15), ArchiveBucket::FifteenToThirtyDays), + ( + Duration::days(15) + millisecond, + ArchiveBucket::FifteenToThirtyDays, + ), + ( + Duration::days(30) - millisecond, + ArchiveBucket::FifteenToThirtyDays, + ), + (Duration::days(30), ArchiveBucket::OlderThanThirtyDays), + ( + Duration::days(30) + millisecond, + ArchiveBucket::OlderThanThirtyDays, + ), + ]; + + for (age, expected) in cases { + assert_eq!(ArchiveBucket::from_age(age), expected, "age: {age:?}"); + } + } + + #[test] + fn from_age_clamps_future_timestamps_to_today() { + assert_eq!( + ArchiveBucket::from_age(Duration::days(-10)), + ArchiveBucket::Today + ); + } +} diff --git a/crates/db/src/models/mod.rs b/crates/db/src/models/mod.rs index 1b95adc165e..0b458157711 100644 --- a/crates/db/src/models/mod.rs +++ b/crates/db/src/models/mod.rs @@ -1,3 +1,4 @@ +pub mod archive_bucket; pub mod claude_session_link; pub mod cli_ingest_outbox; pub mod cli_native_file; diff --git a/crates/db/src/models/workspace.rs b/crates/db/src/models/workspace.rs index f8d138b3aea..681fe29b3ac 100644 --- a/crates/db/src/models/workspace.rs +++ b/crates/db/src/models/workspace.rs @@ -48,6 +48,7 @@ pub struct Workspace { pub created_at: DateTime, pub updated_at: DateTime, pub archived: bool, + pub archived_at: Option>, pub pinned: bool, pub name: Option, pub worktree_deleted: bool, @@ -112,6 +113,7 @@ impl Workspace { created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime", archived AS "archived!: bool", + archived_at AS "archived_at: DateTime", pinned AS "pinned!: bool", name, worktree_deleted AS "worktree_deleted!: bool" @@ -214,6 +216,7 @@ impl Workspace { created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime", archived AS "archived!: bool", + archived_at AS "archived_at: DateTime", pinned AS "pinned!: bool", name, worktree_deleted AS "worktree_deleted!: bool" @@ -236,6 +239,7 @@ impl Workspace { created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime", archived AS "archived!: bool", + archived_at AS "archived_at: DateTime", pinned AS "pinned!: bool", name, worktree_deleted AS "worktree_deleted!: bool" @@ -279,6 +283,7 @@ impl Workspace { w.created_at as "created_at!: DateTime", w.updated_at as "updated_at!: DateTime", w.archived as "archived!: bool", + w.archived_at as "archived_at: DateTime", w.pinned as "pinned!: bool", w.name, w.worktree_deleted as "worktree_deleted!: bool" @@ -329,7 +334,7 @@ impl Workspace { Workspace, r#"INSERT INTO workspaces (id, task_id, container_ref, branch, setup_completed_at, name) VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id as "id!: Uuid", task_id as "task_id: Uuid", container_ref, branch, setup_completed_at as "setup_completed_at: DateTime", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime", archived as "archived!: bool", pinned as "pinned!: bool", name, worktree_deleted as "worktree_deleted!: bool""#, + RETURNING id as "id!: Uuid", task_id as "task_id: Uuid", container_ref, branch, setup_completed_at as "setup_completed_at: DateTime", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime", archived as "archived!: bool", archived_at as "archived_at: DateTime", pinned as "pinned!: bool", name, worktree_deleted as "worktree_deleted!: bool""#, id, Option::::None, Option::::None, @@ -454,7 +459,15 @@ impl Workspace { archived: bool, ) -> Result<(), sqlx::Error> { sqlx::query!( - "UPDATE workspaces SET archived = $1, updated_at = datetime('now', 'subsec') WHERE id = $2", + r#"UPDATE workspaces SET + archived = $1, + archived_at = CASE + WHEN $1 = TRUE AND archived = FALSE THEN datetime('now', 'subsec') + WHEN $1 = FALSE THEN NULL + ELSE archived_at + END, + updated_at = datetime('now', 'subsec') + WHERE id = $2"#, archived, workspace_id ) @@ -479,6 +492,11 @@ impl Workspace { sqlx::query!( r#"UPDATE workspaces SET archived = COALESCE($1, archived), + archived_at = CASE + WHEN $1 = TRUE AND archived = FALSE THEN datetime('now', 'subsec') + WHEN $1 = FALSE THEN NULL + ELSE archived_at + END, pinned = COALESCE($2, pinned), name = CASE WHEN $3 THEN $4 ELSE name END, updated_at = datetime('now', 'subsec') @@ -571,6 +589,7 @@ impl Workspace { w.created_at AS "created_at!: DateTime", w.updated_at AS "updated_at!: DateTime", w.archived AS "archived!: bool", + w.archived_at AS "archived_at: DateTime", w.pinned AS "pinned!: bool", w.name, w.worktree_deleted AS "worktree_deleted!: bool", @@ -639,6 +658,7 @@ impl Workspace { created_at: rec.created_at, updated_at: rec.updated_at, archived: rec.archived, + archived_at: rec.archived_at, pinned: rec.pinned, name: rec.name, worktree_deleted: rec.worktree_deleted, @@ -693,6 +713,7 @@ impl Workspace { w.created_at AS "created_at!: DateTime", w.updated_at AS "updated_at!: DateTime", w.archived AS "archived!: bool", + w.archived_at AS "archived_at: DateTime", w.pinned AS "pinned!: bool", w.name, w.worktree_deleted AS "worktree_deleted!: bool", @@ -764,6 +785,7 @@ impl Workspace { created_at: rec.created_at, updated_at: rec.updated_at, archived: rec.archived, + archived_at: rec.archived_at, pinned: rec.pinned, name: rec.name, worktree_deleted: rec.worktree_deleted, @@ -788,9 +810,35 @@ impl Workspace { #[cfg(test)] mod tests { + use std::time::Duration; + + use sqlx::{SqlitePool, sqlite::SqlitePoolOptions}; use uuid::Uuid; - use super::Workspace; + use super::{CreateWorkspace, Workspace}; + + async fn test_pool() -> SqlitePool { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + crate::run_migrations_for_tests(&pool).await.unwrap(); + pool + } + + async fn create_test_workspace(pool: &SqlitePool) -> Workspace { + Workspace::create( + pool, + &CreateWorkspace { + branch: "archive-timestamp-test".to_string(), + name: Some("Archive timestamp test".to_string()), + }, + Uuid::new_v4(), + ) + .await + .unwrap() + } #[test] fn best_matching_container_ref_prefers_deepest_match() { @@ -825,4 +873,118 @@ mod tests { assert_eq!(selected, None); } + + #[tokio::test] + async fn update_tracks_archive_timestamp_transitions() { + let pool = test_pool().await; + let workspace = create_test_workspace(&pool).await; + + Workspace::update(&pool, workspace.id, Some(true), None, None) + .await + .unwrap(); + let first_archived_at = Workspace::find_by_id(&pool, workspace.id) + .await + .unwrap() + .unwrap() + .archived_at + .expect("archiving should set archived_at"); + + Workspace::update(&pool, workspace.id, Some(true), None, None) + .await + .unwrap(); + Workspace::set_archived(&pool, workspace.id, true) + .await + .unwrap(); + assert_eq!( + Workspace::find_by_id(&pool, workspace.id) + .await + .unwrap() + .unwrap() + .archived_at, + Some(first_archived_at), + "redundant archive writes must preserve the original transition time" + ); + + Workspace::update(&pool, workspace.id, Some(false), None, None) + .await + .unwrap(); + assert_eq!( + Workspace::find_by_id(&pool, workspace.id) + .await + .unwrap() + .unwrap() + .archived_at, + None, + "unarchiving through update must clear archived_at" + ); + + tokio::time::sleep(Duration::from_millis(10)).await; + Workspace::update(&pool, workspace.id, Some(true), None, None) + .await + .unwrap(); + let rearchived_at = Workspace::find_by_id(&pool, workspace.id) + .await + .unwrap() + .unwrap() + .archived_at + .expect("re-archiving should set archived_at again"); + assert!(rearchived_at > first_archived_at); + } + + #[tokio::test] + async fn set_archived_tracks_archive_timestamp_transitions() { + let pool = test_pool().await; + let workspace = create_test_workspace(&pool).await; + + Workspace::set_archived(&pool, workspace.id, true) + .await + .unwrap(); + let first_archived_at = Workspace::find_by_id(&pool, workspace.id) + .await + .unwrap() + .unwrap() + .archived_at + .expect("archiving should set archived_at"); + + Workspace::set_archived(&pool, workspace.id, true) + .await + .unwrap(); + Workspace::update(&pool, workspace.id, Some(true), None, None) + .await + .unwrap(); + assert_eq!( + Workspace::find_by_id(&pool, workspace.id) + .await + .unwrap() + .unwrap() + .archived_at, + Some(first_archived_at), + "redundant archive writes must preserve the original transition time" + ); + + Workspace::set_archived(&pool, workspace.id, false) + .await + .unwrap(); + assert_eq!( + Workspace::find_by_id(&pool, workspace.id) + .await + .unwrap() + .unwrap() + .archived_at, + None, + "unarchiving through set_archived must clear archived_at" + ); + + tokio::time::sleep(Duration::from_millis(10)).await; + Workspace::set_archived(&pool, workspace.id, true) + .await + .unwrap(); + let rearchived_at = Workspace::find_by_id(&pool, workspace.id) + .await + .unwrap() + .unwrap() + .archived_at + .expect("re-archiving should set archived_at again"); + assert!(rearchived_at > first_archived_at); + } } diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index a5a2185a874..95f7f3ff76e 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -74,6 +74,9 @@ tempfile = "3" [build-dependencies] dotenv = "0.15" +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } + [features] default = [] qa-mode = ["services/qa-mode", "executors/qa-mode"] diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index 3cef48528f2..98624cfb1bf 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -45,6 +45,7 @@ fn generate_types_content() -> String { db::models::scratch::UpdateScratch::decl(), db::models::workspace::Workspace::decl(), db::models::workspace::WorkspaceWithStatus::decl(), + db::models::archive_bucket::ArchiveBucket::decl(), db::models::session::Session::decl(), db::models::session_queued_message::QueuedMessageSource::decl(), db::models::session_queued_message::QueuedMessageState::decl(), @@ -144,6 +145,11 @@ fn generate_types_content() -> String { server::routes::sessions::ResetProcessRequest::decl(), server::routes::workspaces::git::ChangeTargetBranchRequest::decl(), server::routes::workspaces::git::ChangeTargetBranchResponse::decl(), + server::routes::workspaces::core::BulkDeleteTarget::decl(), + server::routes::workspaces::core::BulkDeleteArchivedWorkspacesRequest::decl(), + server::routes::workspaces::core::BulkDeleteItemOutcome::decl(), + server::routes::workspaces::core::BulkDeleteItemResult::decl(), + server::routes::workspaces::core::BulkDeleteArchivedWorkspacesResponse::decl(), server::routes::workspaces::repos::AddWorkspaceRepoRequest::decl(), server::routes::workspaces::repos::AddWorkspaceRepoResponse::decl(), server::routes::workspaces::git::MergeWorkspaceRequest::decl(), diff --git a/crates/server/src/routes/workspaces/core.rs b/crates/server/src/routes/workspaces/core.rs index 42977e9c7cb..3d125f97ff9 100644 --- a/crates/server/src/routes/workspaces/core.rs +++ b/crates/server/src/routes/workspaces/core.rs @@ -4,16 +4,19 @@ use axum::{ http::StatusCode, response::Json as ResponseJson, }; +use chrono::{DateTime, Utc}; use db::models::{ coding_agent_turn::CodingAgentTurn, execution_process::{ExecutionProcess, ExecutionProcessStatus}, workspace::{Workspace, WorkspaceError}, }; use deployment::Deployment; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use services::services::{container::ContainerService, diff_stream, remote_sync}; use sqlx::Error as SqlxError; +use ts_rs::TS; use utils::response::ApiResponse; +use uuid::Uuid; use workspace_manager::WorkspaceManager; use crate::{DeploymentImpl, error::ApiError}; @@ -26,6 +29,48 @@ pub struct DeleteWorkspaceQuery { pub delete_branches: bool, } +const RUNNING_PROCESSES_DELETE_MESSAGE: &str = + "Cannot delete workspace while processes are running. Stop all processes first."; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeleteWorkspaceOutcome { + Deleted, + SkippedRunningProcesses, +} + +#[derive(Debug, Deserialize, TS)] +pub struct BulkDeleteTarget { + pub workspace_id: Uuid, + pub archived_at: Option>, +} + +#[derive(Debug, Deserialize, TS)] +pub struct BulkDeleteArchivedWorkspacesRequest { + pub targets: Vec, + pub delete_branches: bool, +} + +#[derive(Debug, Serialize, TS)] +#[serde(tag = "status", rename_all = "snake_case")] +#[ts(tag = "status", rename_all = "snake_case")] +pub enum BulkDeleteItemOutcome { + Deleted, + Skipped { reason: String }, + Failed { reason: String }, +} + +#[derive(Debug, Serialize, TS)] +pub struct BulkDeleteItemResult { + pub workspace_id: Uuid, + pub workspace_name: Option, + pub outcome: BulkDeleteItemOutcome, +} + +#[derive(Debug, Serialize, TS)] +pub struct BulkDeleteArchivedWorkspacesResponse { + pub results: Vec, +} + pub async fn get_workspaces( State(deployment): State, ) -> Result>>, ApiError> { @@ -96,11 +141,144 @@ pub async fn get_first_user_message( Ok(ResponseJson(ApiResponse::success(message))) } +pub async fn bulk_delete_archived_workspaces( + State(deployment): State, + Json(request): Json, +) -> Result>, ApiError> { + let pool = &deployment.db().pool; + let operation_id = Uuid::new_v4(); + let target_count = request.targets.len(); + tracing::info!( + %operation_id, + delete_branches = request.delete_branches, + target_count, + "Starting bulk archived-workspace deletion" + ); + + let mut results = Vec::with_capacity(target_count); + for target in request.targets { + let workspace_id = target.workspace_id; + let fresh_workspace = match Workspace::find_by_id(pool, workspace_id).await { + Ok(Some(workspace)) if !workspace.archived => { + results.push(BulkDeleteItemResult { + workspace_id, + workspace_name: workspace.name, + outcome: BulkDeleteItemOutcome::Skipped { + reason: "no longer archived".to_string(), + }, + }); + continue; + } + Ok(Some(workspace)) => workspace, + Ok(None) => { + results.push(BulkDeleteItemResult { + workspace_id, + workspace_name: None, + outcome: BulkDeleteItemOutcome::Skipped { + reason: "already deleted".to_string(), + }, + }); + continue; + } + Err(error) => { + results.push(BulkDeleteItemResult { + workspace_id, + workspace_name: None, + outcome: BulkDeleteItemOutcome::Failed { + reason: error.to_string(), + }, + }); + continue; + } + }; + let workspace_name = fresh_workspace.name.clone(); + + if fresh_workspace.archived_at != target.archived_at { + results.push(BulkDeleteItemResult { + workspace_id, + workspace_name, + outcome: BulkDeleteItemOutcome::Skipped { + reason: "archive state changed since review".to_string(), + }, + }); + continue; + } + + // Do not make the shared DELETE conditional on archived: single-delete must remove active workspaces. + let outcome = match delete_workspace_core( + &deployment, + fresh_workspace, + false, + request.delete_branches, + ) + .await + { + Ok(DeleteWorkspaceOutcome::Deleted) => BulkDeleteItemOutcome::Deleted, + Ok(DeleteWorkspaceOutcome::SkippedRunningProcesses) => BulkDeleteItemOutcome::Skipped { + reason: RUNNING_PROCESSES_DELETE_MESSAGE.to_string(), + }, + Err(error) => BulkDeleteItemOutcome::Failed { + reason: error.to_string(), + }, + }; + results.push(BulkDeleteItemResult { + workspace_id, + workspace_name, + outcome, + }); + } + + let (deleted, skipped, failed) = results.iter().fold( + (0usize, 0usize, 0usize), + |(deleted, skipped, failed), result| match result.outcome { + BulkDeleteItemOutcome::Deleted => (deleted + 1, skipped, failed), + BulkDeleteItemOutcome::Skipped { .. } => (deleted, skipped + 1, failed), + BulkDeleteItemOutcome::Failed { .. } => (deleted, skipped, failed + 1), + }, + ); + tracing::info!( + %operation_id, + delete_branches = request.delete_branches, + target_count, + deleted, + skipped, + failed, + "Finished bulk archived-workspace deletion" + ); + + Ok(ResponseJson(ApiResponse::success( + BulkDeleteArchivedWorkspacesResponse { results }, + ))) +} + pub async fn delete_workspace( Extension(workspace): Extension, State(deployment): State, Query(query): Query, ) -> Result<(StatusCode, ResponseJson>), ApiError> { + match delete_workspace_core( + &deployment, + workspace, + query.delete_remote, + query.delete_branches, + ) + .await? + { + DeleteWorkspaceOutcome::Deleted => { + Ok((StatusCode::ACCEPTED, ResponseJson(ApiResponse::success(())))) + } + DeleteWorkspaceOutcome::SkippedRunningProcesses => Err(ApiError::Conflict( + RUNNING_PROCESSES_DELETE_MESSAGE.to_string(), + )), + } +} + +pub async fn delete_workspace_core( + deployment: &DeploymentImpl, + workspace: Workspace, + delete_remote: bool, + delete_branches: bool, +) -> Result { let pool = &deployment.db().pool; let workspace_manager = deployment.workspace_manager(); let workspace_id = workspace.id; @@ -108,10 +286,7 @@ pub async fn delete_workspace( if ExecutionProcess::has_running_non_dev_server_processes_for_workspace(pool, workspace_id) .await? { - return Err(ApiError::Conflict( - "Cannot delete workspace while processes are running. Stop all processes first." - .to_string(), - )); + return Ok(DeleteWorkspaceOutcome::SkippedRunningProcesses); } let dev_servers = @@ -163,7 +338,7 @@ pub async fn delete_workspace( ) .await; - if query.delete_remote { + if delete_remote { if let Ok(client) = deployment.remote_client() { match client.delete_workspace(workspace_id).await { Ok(()) => { @@ -185,9 +360,9 @@ pub async fn delete_workspace( } } - WorkspaceManager::spawn_workspace_deletion_cleanup(deletion_context, query.delete_branches); + WorkspaceManager::spawn_workspace_deletion_cleanup(deletion_context, delete_branches); - Ok((StatusCode::ACCEPTED, ResponseJson(ApiResponse::success(())))) + Ok(DeleteWorkspaceOutcome::Deleted) } #[axum::debug_handler] diff --git a/crates/server/src/routes/workspaces/mod.rs b/crates/server/src/routes/workspaces/mod.rs index 3faecbf00ec..1af0d54d0af 100644 --- a/crates/server/src/routes/workspaces/mod.rs +++ b/crates/server/src/routes/workspaces/mod.rs @@ -50,6 +50,10 @@ pub fn router(deployment: &DeploymentImpl) -> Router { ) .route("/start", post(create::create_and_start_workspace)) .route("/from-pr", post(pr::create_workspace_from_pr)) + .route( + "/archived/bulk-delete", + post(core::bulk_delete_archived_workspaces), + ) .route("/streams/ws", get(streams::stream_workspaces_ws)) .route( "/summaries", diff --git a/crates/server/src/routes/workspaces/workspace_summary.rs b/crates/server/src/routes/workspaces/workspace_summary.rs index ff7f7b717ef..c3296142b41 100644 --- a/crates/server/src/routes/workspaces/workspace_summary.rs +++ b/crates/server/src/routes/workspaces/workspace_summary.rs @@ -8,6 +8,7 @@ use db::models::{ pull_request::PullRequest, workspace::Workspace, workspace_cli_activity::WorkspaceCliActivity, + workspace_repo::WorkspaceRepo, }; use deployment::Deployment; use serde::{Deserialize, Serialize}; @@ -27,6 +28,8 @@ pub struct WorkspaceSummaryRequest { #[derive(Debug, Serialize, TS)] pub struct WorkspaceSummary { pub workspace_id: Uuid, + /// Number of repositories/worktrees owned by this workspace. + pub repo_count: usize, /// Session ID of the latest execution process pub latest_session_id: Option, /// Is a tool approval currently pending? @@ -120,7 +123,18 @@ pub async fn get_workspace_summaries( // 6. Get PR status for each workspace let pr_statuses = PullRequest::get_latest_for_workspaces(pool, archived).await?; - // 7. Compute diff stats for each workspace (in parallel) + // 7. Count repositories/worktrees for each workspace (in parallel) + let repo_count_futures = workspaces.iter().map(|workspace| async move { + WorkspaceRepo::find_by_workspace_id(pool, workspace.id) + .await + .map(|repos| (workspace.id, repos.len())) + }); + let repo_counts: HashMap = futures_util::future::join_all(repo_count_futures) + .await + .into_iter() + .collect::>()?; + + // 8. Compute diff stats for each workspace (in parallel) let diff_futures: Vec<_> = workspaces .iter() .map(|ws| { @@ -142,7 +156,7 @@ pub async fn get_workspace_summaries( futures_util::future::join_all(diff_futures).await; let diff_stats: HashMap = diff_results.into_iter().flatten().collect(); - // 8. Assemble response + // 9. Assemble response let summaries: Vec = workspaces .iter() .map(|ws| { @@ -155,6 +169,7 @@ pub async fn get_workspace_summaries( WorkspaceSummary { workspace_id: id, + repo_count: repo_counts.get(&id).copied().unwrap_or_default(), latest_session_id: latest.map(|p| p.session_id), has_pending_approval: has_pending, files_changed: stats.map(|s| s.files_changed), diff --git a/crates/server/tests/bulk_delete_archived_workspaces.rs b/crates/server/tests/bulk_delete_archived_workspaces.rs new file mode 100644 index 00000000000..5c1edb9934a --- /dev/null +++ b/crates/server/tests/bulk_delete_archived_workspaces.rs @@ -0,0 +1,353 @@ +use std::time::Duration as StdDuration; + +use axum::{ + Router, + body::{Body, to_bytes}, + http::{Request, StatusCode}, +}; +use chrono::{Duration, Utc}; +use db::models::{ + archive_bucket::ArchiveBucket, + session::{CreateSession, Session}, + workspace::{CreateWorkspace, Workspace}, +}; +use deployment::Deployment; +use local_deployment::LocalDeployment; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tokio_util::sync::CancellationToken; +use tower::ServiceExt; +use uuid::Uuid; + +async fn create_workspace(deployment: &LocalDeployment, name: &str) -> Workspace { + Workspace::create( + &deployment.db().pool, + &CreateWorkspace { + branch: format!("test-{name}"), + name: Some(name.to_string()), + }, + Uuid::new_v4(), + ) + .await + .unwrap() +} + +async fn create_archived_workspace( + deployment: &LocalDeployment, + name: &str, + age: Duration, +) -> Workspace { + let workspace = create_workspace(deployment, name).await; + Workspace::set_archived(&deployment.db().pool, workspace.id, true) + .await + .unwrap(); + sqlx::query("UPDATE workspaces SET archived_at = ? WHERE id = ?") + .bind(Utc::now() - age) + .bind(workspace.id) + .execute(&deployment.db().pool) + .await + .unwrap(); + Workspace::find_by_id(&deployment.db().pool, workspace.id) + .await + .unwrap() + .unwrap() +} + +async fn add_running_process(deployment: &LocalDeployment, workspace_id: Uuid) { + let session = Session::create( + &deployment.db().pool, + &CreateSession { + executor: None, + name: Some("running process".to_string()), + }, + Uuid::new_v4(), + workspace_id, + ) + .await + .unwrap(); + sqlx::query( + "INSERT INTO execution_processes (id, session_id, run_reason, executor_action, status) VALUES (?, ?, 'codingagent', '{}', 'running')", + ) + .bind(Uuid::new_v4()) + .bind(session.id) + .execute(&deployment.db().pool) + .await + .unwrap(); +} + +fn target(workspace_id: Uuid, archived_at: Value) -> Value { + json!({ + "workspace_id": workspace_id, + "archived_at": archived_at, + }) +} + +fn workspace_target(workspace: &Workspace) -> Value { + target(workspace.id, json!(workspace.archived_at)) +} + +async fn submit_bulk_delete(app: Router, targets: Vec) -> Vec { + let response = app + .oneshot( + Request::post("/workspaces/archived/bulk-delete") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "targets": targets, + "delete_branches": true, + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let payload: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(payload["success"], true); + payload["data"]["results"].as_array().unwrap().clone() +} + +fn result_for(results: &[Value], workspace_id: Uuid) -> &Value { + results + .iter() + .find(|result| result["workspace_id"] == workspace_id.to_string()) + .unwrap_or_else(|| panic!("missing result for workspace {workspace_id}")) +} + +async fn assert_workspace_exists(deployment: &LocalDeployment, workspace_id: Uuid) { + assert!( + Workspace::find_by_id(&deployment.db().pool, workspace_id) + .await + .unwrap() + .is_some(), + "workspace {workspace_id} must remain" + ); +} + +async fn assert_workspace_deleted(deployment: &LocalDeployment, workspace_id: Uuid) { + assert!( + Workspace::find_by_id(&deployment.db().pool, workspace_id) + .await + .unwrap() + .is_none(), + "workspace {workspace_id} must be deleted" + ); +} + +#[tokio::test] +async fn bulk_delete_validates_each_submitted_snapshot_and_continues_after_failures() { + let data_dir = TempDir::new().unwrap(); + // This integration test is its own process and owns the environment before + // LocalDeployment initializes its process-wide data-directory cache. + unsafe { + std::env::set_var("BC_DATA_DIR", data_dir.path()); + std::env::remove_var("BC_SHARED_API_BASE"); + std::env::remove_var("VK_SHARED_API_BASE"); + std::env::remove_var("BC_SHARED_RELAY_API_BASE"); + std::env::remove_var("VK_SHARED_RELAY_API_BASE"); + } + + let shutdown = CancellationToken::new(); + let deployment = LocalDeployment::new(shutdown.child_token()).await.unwrap(); + let app = Router::new() + .merge(server::routes::workspaces::router(&deployment)) + .with_state(deployment.clone()); + + // Only submitted targets are considered. A same-bucket row that was not + // reviewed remains untouched, and active or running targets are skipped. + let submitted = create_archived_workspace(&deployment, "submitted", Duration::days(4)).await; + let not_submitted = + create_archived_workspace(&deployment, "not-submitted", Duration::days(4)).await; + let running = create_archived_workspace(&deployment, "running", Duration::days(5)).await; + let active = create_workspace(&deployment, "active").await; + add_running_process(&deployment, running.id).await; + + let results = submit_bulk_delete( + app.clone(), + vec![ + workspace_target(&submitted), + workspace_target(&running), + workspace_target(&active), + ], + ) + .await; + assert_eq!(results.len(), 3); + assert_eq!( + result_for(&results, submitted.id)["outcome"]["status"], + "deleted" + ); + assert_eq!( + result_for(&results, running.id)["outcome"]["status"], + "skipped" + ); + assert!( + result_for(&results, running.id)["outcome"]["reason"] + .as_str() + .unwrap() + .contains("processes are running") + ); + assert_eq!( + result_for(&results, active.id)["outcome"]["reason"], + "no longer archived" + ); + assert!( + results + .iter() + .all(|result| result["workspace_id"] != not_submitted.id.to_string()), + "an unsubmitted workspace must not even receive an outcome" + ); + assert_workspace_deleted(&deployment, submitted.id).await; + for untouched_id in [not_submitted.id, running.id, active.id] { + assert_workspace_exists(&deployment, untouched_id).await; + } + + // Exact archived_at equality rejects an unarchive/re-archive transition, + // but natural aging across a bucket boundary preserves the same timestamp. + let changed = create_archived_workspace(&deployment, "changed", Duration::days(4)).await; + let original_changed_timestamp = json!(changed.archived_at); + Workspace::set_archived(&deployment.db().pool, changed.id, false) + .await + .unwrap(); + tokio::time::sleep(StdDuration::from_millis(10)).await; + Workspace::set_archived(&deployment.db().pool, changed.id, true) + .await + .unwrap(); + + let aging = create_archived_workspace(&deployment, "aging", Duration::days(2)).await; + let review_now = Utc::now(); + let boundary_timestamp = review_now - Duration::days(3) + Duration::milliseconds(250); + sqlx::query("UPDATE workspaces SET archived_at = ? WHERE id = ?") + .bind(boundary_timestamp) + .bind(aging.id) + .execute(&deployment.db().pool) + .await + .unwrap(); + let aging = Workspace::find_by_id(&deployment.db().pool, aging.id) + .await + .unwrap() + .unwrap(); + assert_eq!( + ArchiveBucket::from_age(review_now.signed_duration_since(aging.archived_at.unwrap())), + ArchiveBucket::OneToThreeDays + ); + tokio::time::sleep(StdDuration::from_millis(300)).await; + assert_eq!( + ArchiveBucket::from_age(Utc::now().signed_duration_since(aging.archived_at.unwrap())), + ArchiveBucket::ThreeToSevenDays + ); + + let null_timestamp = + create_archived_workspace(&deployment, "null-timestamp", Duration::days(40)).await; + sqlx::query("UPDATE workspaces SET archived_at = NULL WHERE id = ?") + .bind(null_timestamp.id) + .execute(&deployment.db().pool) + .await + .unwrap(); + let null_timestamp = Workspace::find_by_id(&deployment.db().pool, null_timestamp.id) + .await + .unwrap() + .unwrap(); + assert!(null_timestamp.archived); + assert_eq!(null_timestamp.archived_at, None); + + // This real HTTP request exercises JSON/serde timestamp precision for both + // Some(timestamp) and None snapshots. + let results = submit_bulk_delete( + app.clone(), + vec![ + target(changed.id, original_changed_timestamp), + workspace_target(&aging), + workspace_target(&null_timestamp), + ], + ) + .await; + assert_eq!( + result_for(&results, changed.id)["outcome"]["reason"], + "archive state changed since review" + ); + assert_eq!( + result_for(&results, aging.id)["outcome"]["status"], + "deleted" + ); + assert_eq!( + result_for(&results, null_timestamp.id)["outcome"]["status"], + "deleted" + ); + assert_workspace_exists(&deployment, changed.id).await; + assert_workspace_deleted(&deployment, aging.id).await; + assert_workspace_deleted(&deployment, null_timestamp.id).await; + + // A missing target is itemized without preventing another target from + // being deleted. + let already_deleted = + create_archived_workspace(&deployment, "already-deleted", Duration::days(8)).await; + let already_deleted_target = workspace_target(&already_deleted); + Workspace::delete(&deployment.db().pool, already_deleted.id) + .await + .unwrap(); + let after_missing = + create_archived_workspace(&deployment, "after-missing", Duration::days(8)).await; + let results = submit_bulk_delete( + app.clone(), + vec![already_deleted_target, workspace_target(&after_missing)], + ) + .await; + assert_eq!( + result_for(&results, already_deleted.id)["outcome"]["reason"], + "already deleted" + ); + assert_eq!( + result_for(&results, after_missing.id)["outcome"]["status"], + "deleted" + ); + assert_workspace_deleted(&deployment, after_missing.id).await; + + // Force one DELETE statement to fail and prove the handler still processes + // the following target. + let forced_failure = + create_archived_workspace(&deployment, "forced-failure", Duration::days(9)).await; + let after_failure = + create_archived_workspace(&deployment, "after-failure", Duration::days(9)).await; + let failed_id_hex = forced_failure.id.simple().to_string().to_ascii_uppercase(); + sqlx::query(&format!( + "CREATE TRIGGER fail_selected_workspace_delete + BEFORE DELETE ON workspaces + WHEN hex(OLD.id) = '{failed_id_hex}' + BEGIN + SELECT RAISE(FAIL, 'forced deletion failure'); + END" + )) + .execute(&deployment.db().pool) + .await + .unwrap(); + + let results = submit_bulk_delete( + app, + vec![ + workspace_target(&forced_failure), + workspace_target(&after_failure), + ], + ) + .await; + assert_eq!( + result_for(&results, forced_failure.id)["outcome"]["status"], + "failed" + ); + assert!( + result_for(&results, forced_failure.id)["outcome"]["reason"] + .as_str() + .unwrap() + .contains("forced deletion failure") + ); + assert_eq!( + result_for(&results, after_failure.id)["outcome"]["status"], + "deleted" + ); + assert_workspace_exists(&deployment, forced_failure.id).await; + assert_workspace_deleted(&deployment, after_failure.id).await; + + shutdown.cancel(); +} diff --git a/crates/workspace-manager/src/workspace_manager.rs b/crates/workspace-manager/src/workspace_manager.rs index 8a50372ac22..6545982116b 100644 --- a/crates/workspace-manager/src/workspace_manager.rs +++ b/crates/workspace-manager/src/workspace_manager.rs @@ -262,6 +262,7 @@ impl WorkspaceManager { if delete_branches { let git_service = GitService::new(); for repo_path in repo_paths { + let _repo_guard = WorktreeManager::lock_repo_mutation(&repo_path).await; match git_service.delete_branch(&repo_path, &branch_name) { Ok(()) => { info!("Deleted branch '{}' from repo {:?}", branch_name, repo_path); diff --git a/crates/worktree-manager/src/worktree_manager.rs b/crates/worktree-manager/src/worktree_manager.rs index 48202129689..fe1104257b2 100644 --- a/crates/worktree-manager/src/worktree_manager.rs +++ b/crates/worktree-manager/src/worktree_manager.rs @@ -16,6 +16,10 @@ use utils::{path::normalize_macos_private_alias, shell::resolve_executable_path} static WORKTREE_CREATION_LOCKS: LazyLock>>>> = LazyLock::new(|| Mutex::new(HashMap::new())); +// Serialize git metadata mutations across different worktrees that share a repo. +static REPO_MUTATION_LOCKS: LazyLock>>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + #[derive(Debug, Clone)] pub struct WorktreeCleanup { pub worktree_path: PathBuf, @@ -52,6 +56,20 @@ pub enum WorktreeError { pub struct WorktreeManager; impl WorktreeManager { + pub async fn lock_repo_mutation(repo_path: &Path) -> tokio::sync::OwnedMutexGuard<()> { + let normalized_path = normalize_macos_private_alias(repo_path); + let canonical_path = dunce::canonicalize(&normalized_path).unwrap_or(normalized_path); + let lock = { + let mut locks = REPO_MUTATION_LOCKS.lock().unwrap(); + locks + .entry(canonical_path) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() + }; + + lock.lock_owned().await + } + pub fn set_workspace_dir_override(path: PathBuf) { let _ = WORKSPACE_DIR_OVERRIDE.set(path); } @@ -423,6 +441,7 @@ impl WorktreeManager { }; if let Some(repo_path) = resolved_repo_path { + let _repo_guard = Self::lock_repo_mutation(&repo_path).await; Self::comprehensive_worktree_cleanup_async(&repo_path, &worktree.worktree_path).await?; } else { // Can't determine repo path, just clean up the worktree directory @@ -589,3 +608,32 @@ async fn create_worktree_when_repo_path_is_a_worktree() { .await .unwrap(); } + +#[tokio::test] +async fn repo_mutation_lock_uses_the_canonical_repo_path() { + use std::time::Duration; + + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path().join("repo"); + let nested_path = repo_path.join("nested"); + std::fs::create_dir_all(&nested_path).unwrap(); + + let first_guard = WorktreeManager::lock_repo_mutation(&repo_path).await; + let aliased_repo_path = nested_path.join(".."); + let waiter = + tokio::spawn(async move { WorktreeManager::lock_repo_mutation(&aliased_repo_path).await }); + + tokio::task::yield_now().await; + assert!( + !waiter.is_finished(), + "canonical aliases must contend on the same repository lock" + ); + + drop(first_guard); + tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("repo lock waiter should proceed after release") + .expect("repo lock waiter task should complete"); +} diff --git a/package.json b/package.json index 82815816638..6c6245cab7b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "format": "pnpm run backend:format && pnpm run web-core:format && pnpm run local-web:format && pnpm run remote-web:format && pnpm run ui:format", "check": "pnpm run local-web:legacy-path-guard && pnpm run local-web:check && pnpm run remote-web:check && pnpm run web-core:check && pnpm run ui:check && pnpm run backend:check", "dev": "export FRONTEND_PORT=$(node scripts/setup-dev-environment.js frontend) && export BACKEND_PORT=$(node scripts/setup-dev-environment.js backend) && export PREVIEW_PROXY_PORT=$(node scripts/setup-dev-environment.js preview_proxy) && export VK_ALLOWED_ORIGINS=\"http://localhost:${FRONTEND_PORT}\" && export VITE_VK_SHARED_API_BASE=${VK_SHARED_API_BASE:-} && concurrently \"pnpm run backend:dev:watch\" \"pnpm run local-web:dev\"", + "test": "vitest run", "test:npm": "./test-npm-package.sh", "local-web:lint": "pnpm --filter @vibe/local-web run lint", "local-web:legacy-path-guard": "./scripts/check-legacy-frontend-paths.sh", @@ -60,7 +61,8 @@ "esbuild": "^0.27.2", "jwt-decode": "^4.0.0", "typescript": "^5.7.0", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "3.2.4" }, "engines": { "node": ">=20", diff --git a/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx b/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx new file mode 100644 index 00000000000..306dbd2a2ad --- /dev/null +++ b/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx @@ -0,0 +1,533 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + CheckCircleIcon, + FolderOpenIcon, + GitBranchIcon, + SpinnerIcon, + WarningCircleIcon, + WarningIcon, + XCircleIcon, +} from '@phosphor-icons/react'; +import { useTranslation } from 'react-i18next'; +import type { BulkDeleteTarget } from 'shared/types'; +import { + inspectArchivedWorkspaceTargets, + resolveDialogTotals, + sumRepoCounts, + type BulkDeleteArchivedWorkspaceDetails, +} from '../lib/bulkDeleteArchivedWorkspaces'; +import { Button } from './Button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './KeyboardDialog'; + +export interface BulkDeleteDialogBranchStatus { + commitsAhead: number | null; +} + +export type BulkDeleteDialogItemOutcome = + | { status: 'deleted' } + | { status: 'skipped'; reason: string } + | { status: 'failed'; reason: string }; + +export interface BulkDeleteDialogItemResult { + workspaceId: string; + workspaceName: string | null; + outcome: BulkDeleteDialogItemOutcome; +} + +export interface BulkDeleteArchivedWorkspacesDialogProps { + open: boolean; + bucketLabel: string; + targets: BulkDeleteTarget[]; + detailsByWorkspaceId: Readonly< + Record + >; + onOpenChange: (open: boolean) => void; + inspectWorkspace: ( + workspaceId: string + ) => Promise; + onConfirm: ( + targets: BulkDeleteTarget[] + ) => Promise; +} + +interface InspectionSummary { + branchCount: number; + worktreeCount: number; + unmergedBranchCount: number; + unmergedWorkspaceCount: number; + unknownComparisonCount: number; + inspectionFailureCount: number; + worktreeAlreadyRemovedCount: number; +} + +function OutcomeIcon({ + status, +}: { + status: BulkDeleteDialogItemOutcome['status']; +}) { + if (status === 'deleted') { + return ( + + ); + } + if (status === 'skipped') { + return ( + + ); + } + return ; +} + +export function BulkDeleteArchivedWorkspacesDialog({ + open, + bucketLabel, + targets, + detailsByWorkspaceId, + onOpenChange, + inspectWorkspace, + onConfirm, +}: BulkDeleteArchivedWorkspacesDialogProps) { + const { t } = useTranslation('common'); + const [inspection, setInspection] = useState(null); + const [inspectionError, setInspectionError] = useState(null); + const [inspectionAttempt, setInspectionAttempt] = useState(0); + const [isInspecting, setIsInspecting] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [operationError, setOperationError] = useState(null); + const [results, setResults] = useState( + null + ); + + const initialRepoCount = useMemo( + () => sumRepoCounts(targets, detailsByWorkspaceId), + [detailsByWorkspaceId, targets] + ); + + const initialWorktreeCount = useMemo(() => { + const targetsWithWorktrees = targets.filter( + (target) => !detailsByWorkspaceId[target.workspace_id]?.worktreeDeleted + ); + if ( + targetsWithWorktrees.some( + (target) => + detailsByWorkspaceId[target.workspace_id]?.repoCount === undefined + ) + ) { + return null; + } + return targetsWithWorktrees.reduce( + (count, target) => + count + (detailsByWorkspaceId[target.workspace_id]?.repoCount ?? 0), + 0 + ); + }, [detailsByWorkspaceId, targets]); + + useEffect(() => { + if (!open) return; + + let canceled = false; + setResults(null); + setOperationError(null); + setInspection(null); + setInspectionError(null); + setIsInspecting(true); + + void inspectArchivedWorkspaceTargets( + targets, + inspectWorkspace, + (workspaceId) => + detailsByWorkspaceId[workspaceId]?.worktreeDeleted === true + ) + .then((workspaceStatuses) => { + if (canceled) return; + + const branchCount = workspaceStatuses.reduce( + (count, item) => count + item.statuses.length, + 0 + ); + const unmergedBranchCount = workspaceStatuses.reduce( + (count, item) => + count + + item.statuses.filter((status) => (status.commitsAhead ?? 0) > 0) + .length, + 0 + ); + const unmergedWorkspaceCount = workspaceStatuses.filter((item) => + item.statuses.some((status) => (status.commitsAhead ?? 0) > 0) + ).length; + const unknownComparisonCount = workspaceStatuses.reduce( + (count, item) => + count + + item.statuses.filter((status) => status.commitsAhead === null) + .length, + 0 + ); + const inspectionFailureCount = workspaceStatuses.filter( + (item) => item.inspectionFailed + ).length; + const worktreeAlreadyRemovedCount = workspaceStatuses.filter( + (item) => item.worktreeAlreadyRemoved + ).length; + + setInspection({ + branchCount, + worktreeCount: branchCount, + unmergedBranchCount, + unmergedWorkspaceCount, + unknownComparisonCount, + inspectionFailureCount, + worktreeAlreadyRemovedCount, + }); + }) + .catch((error: unknown) => { + if (canceled) return; + setInspectionError( + error instanceof Error + ? error.message + : t('kanban.workspaceSidebar.bulkDeleteInspectionFailed', { + defaultValue: 'Could not inspect every branch.', + }) + ); + }) + .finally(() => { + if (!canceled) setIsInspecting(false); + }); + + return () => { + canceled = true; + }; + }, [ + detailsByWorkspaceId, + inspectWorkspace, + inspectionAttempt, + open, + t, + targets, + ]); + + const resultCounts = useMemo(() => { + if (!results) return null; + return results.reduce( + (counts, result) => { + counts[result.outcome.status] += 1; + return counts; + }, + { deleted: 0, skipped: 0, failed: 0 } + ); + }, [results]); + + const handleConfirm = async () => { + if (!inspection || inspectionError || isInspecting || isDeleting) return; + + setIsDeleting(true); + setOperationError(null); + try { + setResults(await onConfirm(targets)); + } catch (error) { + setOperationError( + error instanceof Error + ? error.message + : t('kanban.workspaceSidebar.bulkDeleteRequestFailed', { + defaultValue: 'The bulk removal request failed.', + }) + ); + } finally { + setIsDeleting(false); + } + }; + + const { branchCount, worktreeCount } = resolveDialogTotals({ + initialRepoCount, + initialWorktreeCount, + inspection, + }); + + return ( + { + if (!isDeleting) onOpenChange(nextOpen); + }} + uncloseable={isDeleting} + > + + +
+ + + {results + ? t('kanban.workspaceSidebar.bulkDeleteResultsTitle', { + defaultValue: 'Removal results', + }) + : t('kanban.workspaceSidebar.bulkDeleteTitle', { + defaultValue: 'Remove archived workspaces', + })} + +
+ + {results + ? t('kanban.workspaceSidebar.bulkDeleteResultsDescription', { + defaultValue: + 'Each workspace is reported separately so skipped or failed removals are visible.', + }) + : t('kanban.workspaceSidebar.bulkDeleteDescription', { + bucket: bucketLabel, + defaultValue: + 'This permanently removes the workspaces listed below from “{{bucket}}” if their archive state is unchanged. This action cannot be undone.', + })} + +
+ + {results && resultCounts ? ( +
+

+ {t('kanban.workspaceSidebar.bulkDeleteResultSummary', { + deleted: resultCounts.deleted, + skipped: resultCounts.skipped, + failed: resultCounts.failed, + defaultValue: + '{{deleted}} removed, {{skipped}} skipped, {{failed}} failed', + })} +

+
+ {results.map((result) => ( +
+ +
+
+ + {result.workspaceName ?? + detailsByWorkspaceId[result.workspaceId] + ?.workspaceName ?? + result.workspaceId} + + + {result.outcome.status} + +
+ {'reason' in result.outcome && ( +

+ {result.outcome.reason} +

+ )} +
+
+ ))} +
+
+ ) : ( +
{ + event.preventDefault(); + void handleConfirm(); + }} + > +
+
+
+ {t('kanban.workspaceSidebar.bulkDeleteWorkspaces', { + defaultValue: 'Workspaces', + })} +
+
+ {targets.length} +
+
+ + {t('kanban.workspaceSidebar.bulkDeleteBranches', { + defaultValue: 'Branches deleted', + })} +
+
+ {branchCount ?? '—'} +
+
+ + {t('kanban.workspaceSidebar.bulkDeleteWorktrees', { + defaultValue: 'Worktrees removed', + })} +
+
+ {worktreeCount ?? '—'} +
+
+ {t('kanban.workspaceSidebar.bulkDeleteUnmerged', { + defaultValue: 'Branches with unmerged commits', + })} +
+
+ {isInspecting ? ( + + + {t('kanban.workspaceSidebar.bulkDeleteInspecting', { + defaultValue: 'Inspecting…', + })} + + ) : inspection ? ( + + {inspection.unmergedBranchCount} + + {t( + 'kanban.workspaceSidebar.bulkDeleteUnmergedWorkspaces', + { + count: inspection.unmergedWorkspaceCount, + defaultValue: 'across {{count}} workspace(s)', + } + )} + + + ) : ( + '—' + )} +
+ {inspection && inspection.unknownComparisonCount > 0 && ( + <> +
+ {t( + 'kanban.workspaceSidebar.bulkDeleteUnknownComparison', + { + defaultValue: 'Branches that could not be compared', + } + )} +
+
+ {inspection.unknownComparisonCount} +
+ + )} + {inspection && inspection.inspectionFailureCount > 0 && ( + <> +
+ {t( + 'kanban.workspaceSidebar.bulkDeleteInspectionFailures', + { + defaultValue: + 'Workspaces whose unmerged status is unknown', + } + )} +
+
+ {inspection.inspectionFailureCount} +
+ + )} + {inspection && inspection.worktreeAlreadyRemovedCount > 0 && ( + <> +
+ {t( + 'kanban.workspaceSidebar.bulkDeleteWorktreeAlreadyRemoved', + { + defaultValue: + 'Unmerged status unknown, worktree already removed', + } + )} +
+
+ {inspection.worktreeAlreadyRemovedCount} +
+ + )} +
+
+ + {inspectionError && ( +
+ {inspectionError} + +
+ )} + +
+

+ {t('kanban.workspaceSidebar.bulkDeleteTargets', { + defaultValue: 'Workspaces that will be removed', + })} +

+
    + {targets.map((target) => ( +
  • + {detailsByWorkspaceId[target.workspace_id]?.workspaceName ?? + target.workspace_id} +
  • + ))} +
+
+ + {operationError && ( +

+ {operationError} +

+ )} + + + + + +
+ )} + + {results && ( + + + + )} +
+
+ ); +} diff --git a/packages/ui/src/components/CollapsibleSectionHeader.tsx b/packages/ui/src/components/CollapsibleSectionHeader.tsx index f6067a45ea1..a26e859faab 100644 --- a/packages/ui/src/components/CollapsibleSectionHeader.tsx +++ b/packages/ui/src/components/CollapsibleSectionHeader.tsx @@ -1,4 +1,4 @@ -import type { KeyboardEvent, MouseEvent, ReactNode } from 'react'; +import type { ReactNode } from 'react'; import { useEffect, useState } from 'react'; import type { Icon } from '@phosphor-icons/react'; import { CaretDownIcon } from '@phosphor-icons/react'; @@ -26,6 +26,7 @@ export type SectionAction = { icon: Icon; onClick: () => void; isActive?: boolean; + ariaLabel?: string; }; interface CollapsibleSectionHeaderProps { @@ -37,6 +38,7 @@ interface CollapsibleSectionHeaderProps { headerExtra?: ReactNode; children?: ReactNode; className?: string; + titleClassName?: string; } export function CollapsibleSectionHeader({ @@ -48,6 +50,7 @@ export function CollapsibleSectionHeader({ headerExtra, children, className, + titleClassName, }: CollapsibleSectionHeaderProps) { const [expanded, setExpanded] = useState(() => getInitialExpanded(persistKey, defaultExpanded) @@ -69,84 +72,65 @@ export function CollapsibleSectionHeader({ } }, [persistKey, expanded]); - const handleActionClick = ( - e: MouseEvent, - onClick: () => void - ) => { - e.stopPropagation(); - onClick(); - }; - - const handleActionKeyDown = ( - e: KeyboardEvent, - onClick: () => void - ) => { - if (e.key !== 'Enter' && e.key !== ' ') return; - e.preventDefault(); - e.stopPropagation(); - onClick(); - }; - const isExpanded = collapsible ? expanded : true; - const headerContent = ( - <> - {title} -
- {headerExtra} - {actions.map((action, index) => { - const ActionIcon = action.icon; - return ( - handleActionClick(e, action.onClick)} - onKeyDown={(e) => handleActionKeyDown(e, action.onClick)} - className={cn( - 'hover:text-normal', - action.isActive ? 'text-brand' : 'text-low' - )} - > - - - ); - })} - {collapsible && ( - - )} -
- - ); - return (
-
+
{collapsible ? ( ) : ( -
- {headerContent} -
+ {title} + )} +
+ {headerExtra} + {actions.map((action, index) => { + const ActionIcon = action.icon; + return ( + + ); + })} +
{isExpanded && children}
diff --git a/packages/ui/src/components/WorkspaceSummary.tsx b/packages/ui/src/components/WorkspaceSummary.tsx index 9ec0e2c0c0c..985d78686f9 100644 --- a/packages/ui/src/components/WorkspaceSummary.tsx +++ b/packages/ui/src/components/WorkspaceSummary.tsx @@ -45,6 +45,9 @@ export interface WorkspaceSummaryProps { onClick?: () => void; className?: string; summary?: boolean; + summaryTimestamp?: string; + mutedSummary?: boolean; + actionLabel?: string; /** Whether this is a draft workspace (shows "Draft" instead of elapsed time) */ isDraft?: boolean; onOpenWorkspaceActions?: (workspaceId: string) => void; @@ -68,6 +71,9 @@ export function WorkspaceSummary({ onClick, className, summary = false, + summaryTimestamp, + mutedSummary = false, + actionLabel, isDraft = false, onOpenWorkspaceActions, }: WorkspaceSummaryProps) { @@ -98,28 +104,41 @@ export function WorkspaceSummary({ )} /> + + ) : ( + + + + + + + setBulkDeleteTarget({ + label, + targets: bucketState.targets, + detailsByWorkspaceId: + bucketState.detailsByWorkspaceId, + }) + } + > + + {t('common:workspaces.removeArchiveBucket', { + defaultValue: 'Remove all in this bucket', + })} + + + + ) + ) : null + } + > +
+ {bucketState.visibleWorkspaces.map((workspace) => ( + onSelectWorkspace(workspace.id)} + /> + ))} +
+ + ); + }) )}
- ) : layoutMode === 'accordion' ? ( - /* Accordion layout view */ -
- {/* Needs Attention section */} - -
+ ) : ( + <> + {layoutMode === 'accordion' ? ( + /* Accordion layout view */ +
+ {/* Needs Attention section */} + +
+ {draftTitle && ( + + )} + {raisedHandWorkspaces.length === 0 && !draftTitle ? ( + + {t('common:workspaces.noWorkspaces')} + + ) : ( + + )} +
+
+ + {/* Running section */} + +
+ {runningWorkspaces.length === 0 ? ( + + {t('common:workspaces.noWorkspaces')} + + ) : ( + + )} +
+
+ + {/* Idle section */} + +
+ {idleWorkspaces.length === 0 ? ( + + {t('common:workspaces.noWorkspaces')} + + ) : ( + + )} +
+
+
+ ) : ( + /* Active workspaces flat view */ +
+
+ + {t('common:workspaces.active')} + + + {totalWorkspacesCount} + +
{draftTitle && ( )} - {raisedHandWorkspaces.length === 0 && !draftTitle ? ( - - {t('common:workspaces.noWorkspaces')} - - ) : ( - - )} -
- - - {/* Running section */} - -
- {runningWorkspaces.length === 0 ? ( - - {t('common:workspaces.noWorkspaces')} - - ) : ( - ( + onSelectWorkspace(workspace.id)} /> - )} + ))}
-
+ )} - {/* Idle section */} - -
- {idleWorkspaces.length === 0 ? ( - - {t('common:workspaces.noWorkspaces')} - - ) : ( - - )} + {recentlyArchivedWorkspaces.length > 0 && onRestoreWorkspace && ( +
+ {/* Deliberately omit persistKey so this resets collapsed. */} + +
+ {recentlyArchivedWorkspaces.map((workspace) => ( + void handleRestoreWorkspace(workspace)} + /> + ))} + {restoreError && ( +

+ {restoreError} +

+ )} +
+
- -
- ) : ( - /* Active workspaces flat view */ -
-
- - {t('common:workspaces.active')} - - {totalWorkspacesCount} -
- {draftTitle && ( - )} - {workspaces.map((workspace) => ( - onSelectWorkspace(workspace.id)} - /> - ))} -
+ )}
@@ -498,6 +749,21 @@ export function WorkspacesSidebar({ )}
+ {bulkDeleteTarget && + inspectArchivedWorkspace && + onBulkDeleteArchivedBucket && ( + { + if (!open) setBulkDeleteTarget(null); + }} + /> + )} ); } diff --git a/packages/ui/src/lib/archiveBuckets.ts b/packages/ui/src/lib/archiveBuckets.ts new file mode 100644 index 00000000000..0979e521da2 --- /dev/null +++ b/packages/ui/src/lib/archiveBuckets.ts @@ -0,0 +1,63 @@ +import type { ArchiveBucket } from 'shared/types'; + +export const ARCHIVE_BUCKET_ORDER: readonly ArchiveBucket[] = [ + 'today', + 'one_to_three_days', + 'three_to_seven_days', + 'seven_to_fifteen_days', + 'fifteen_to_thirty_days', + 'older_than_thirty_days', +]; + +export const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000; + +/** + * Maps a duration to the same half-open archive-age buckets as + * `crates/db/src/models/archive_bucket.rs`. Keep both boundary tables in sync. + */ +export function archiveBucketFromAgeMilliseconds( + ageMilliseconds: number +): ArchiveBucket { + if (!Number.isFinite(ageMilliseconds)) { + return 'older_than_thirty_days'; + } + + const ageDays = Math.floor( + Math.max(0, ageMilliseconds) / DAY_IN_MILLISECONDS + ); + + if (ageDays === 0) return 'today'; + if (ageDays < 3) return 'one_to_three_days'; + if (ageDays < 7) return 'three_to_seven_days'; + if (ageDays < 15) return 'seven_to_fifteen_days'; + if (ageDays < 30) return 'fifteen_to_thirty_days'; + return 'older_than_thirty_days'; +} + +export function archiveBucketForTimestamp( + archivedAt: string | null, + nowMilliseconds = Date.now() +): ArchiveBucket { + if (!archivedAt) { + // Unknown archive ages belong in the oldest bucket. Treating them as fresh + // would make a destructive action look safer than it is. + return 'older_than_thirty_days'; + } + + const archivedAtMilliseconds = Date.parse(archivedAt); + if (!Number.isFinite(archivedAtMilliseconds)) { + return 'older_than_thirty_days'; + } + + return archiveBucketFromAgeMilliseconds( + nowMilliseconds - archivedAtMilliseconds + ); +} + +export function isArchivedRecently( + archivedAt: string | null, + nowMilliseconds = Date.now() +): boolean { + const bucket = archiveBucketForTimestamp(archivedAt, nowMilliseconds); + return bucket === 'today' || bucket === 'one_to_three_days'; +} diff --git a/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.test.ts b/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.test.ts new file mode 100644 index 00000000000..7e0ac5be873 --- /dev/null +++ b/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from 'vitest'; +import { archiveBucketForTimestamp } from './archiveBuckets'; +import { + buildArchivedBucketState, + inspectArchivedWorkspaceTargets, + resolveDialogTotals, + sumRepoCounts, +} from './bulkDeleteArchivedWorkspaces'; + +describe('sumRepoCounts', () => { + const targets = ['one-repo', 'two-repos', 'three-repos'].map( + (workspaceId) => ({ + workspace_id: workspaceId, + archived_at: '2026-06-01T12:00:00Z', + }) + ); + const detailsByWorkspaceId = { + 'one-repo': { + workspaceName: 'One repo', + repoCount: 1, + worktreeDeleted: false, + }, + 'two-repos': { + workspaceName: 'Two repos', + repoCount: 2, + worktreeDeleted: false, + }, + 'three-repos': { + workspaceName: 'Three repos', + repoCount: 3, + worktreeDeleted: false, + }, + }; + + it('sums mixed repository counts across the targeted workspaces', () => { + expect(sumRepoCounts(targets, detailsByWorkspaceId)).toBe(6); + }); + + it('returns null when any targeted workspace has an unknown repository count', () => { + expect( + sumRepoCounts(targets, { + ...detailsByWorkspaceId, + 'two-repos': { + ...detailsByWorkspaceId['two-repos'], + repoCount: undefined, + }, + }) + ).toBeNull(); + }); +}); + +describe('resolveDialogTotals', () => { + const completeInspection = { + branchCount: 6, + worktreeCount: 5, + inspectionFailureCount: 0, + worktreeAlreadyRemovedCount: 0, + }; + + it('uses inspection totals when initial totals are unavailable and inspection is complete', () => { + expect( + resolveDialogTotals({ + initialRepoCount: null, + initialWorktreeCount: null, + inspection: completeInspection, + }) + ).toEqual({ + branchCount: 6, + worktreeCount: 5, + }); + }); + + it.each([ + ['an inspection failure', 1, 0], + ['a removed-worktree skip', 0, 1], + ])( + 'returns unknown fallback totals after %s', + (_reason, inspectionFailureCount, worktreeAlreadyRemovedCount) => { + expect( + resolveDialogTotals({ + initialRepoCount: null, + initialWorktreeCount: null, + inspection: { + ...completeInspection, + inspectionFailureCount, + worktreeAlreadyRemovedCount, + }, + }) + ).toEqual({ + branchCount: null, + worktreeCount: null, + }); + } + ); + + it('prefers initial totals regardless of incomplete inspection state', () => { + expect( + resolveDialogTotals({ + initialRepoCount: 8, + initialWorktreeCount: 7, + inspection: { + ...completeInspection, + inspectionFailureCount: 1, + worktreeAlreadyRemovedCount: 1, + }, + }) + ).toEqual({ + branchCount: 8, + worktreeCount: 7, + }); + }); +}); + +describe('buildArchivedBucketState', () => { + it('builds targets from the full bucket despite search and pagination visibility', () => { + const fullBucket = Array.from({ length: 55 }, (_, index) => ({ + id: `workspace-${index}`, + name: `Workspace ${index}`, + archivedAt: index === 54 ? null : '2026-06-01T12:00:00Z', + })); + const paginatedIds = new Set( + fullBucket.slice(0, 50).map((workspace) => workspace.id) + ); + const searchMatchedIds = new Set( + fullBucket + .filter((workspace) => workspace.name.endsWith('1')) + .map((workspace) => workspace.id) + .filter((workspaceId) => paginatedIds.has(workspaceId)) + ); + + const { targets, detailsByWorkspaceId, visibleWorkspaces } = + buildArchivedBucketState(fullBucket, searchMatchedIds); + + expect(visibleWorkspaces.map((workspace) => workspace.id)).toEqual([ + 'workspace-1', + 'workspace-11', + 'workspace-21', + 'workspace-31', + 'workspace-41', + ]); + expect(targets).toHaveLength(fullBucket.length); + expect(targets.map((target) => target.workspace_id)).toEqual( + fullBucket.map((workspace) => workspace.id) + ); + expect(targets.at(-1)?.archived_at).toBeNull(); + expect(detailsByWorkspaceId['workspace-54'].workspaceName).toBe( + 'Workspace 54' + ); + expect( + archiveBucketForTimestamp( + fullBucket[54].archivedAt, + Date.UTC(2026, 6, 22, 12) + ) + ).toBe('older_than_thirty_days'); + }); +}); + +describe('inspectArchivedWorkspaceTargets', () => { + it('preserves successful results when one workspace inspection fails', async () => { + const targets = ['first', 'failing', 'last'].map((workspaceId) => ({ + workspace_id: workspaceId, + archived_at: '2026-06-01T12:00:00Z', + })); + + const results = await inspectArchivedWorkspaceTargets( + targets, + async (workspaceId) => { + if (workspaceId === 'failing') { + throw new Error('transient inspection failure'); + } + return [{ commitsAhead: workspaceId === 'first' ? 1 : 0 }]; + } + ); + + expect(results).toEqual([ + { + target: targets[0], + statuses: [{ commitsAhead: 1 }], + inspectionFailed: false, + worktreeAlreadyRemoved: false, + }, + { + target: targets[1], + statuses: [], + inspectionFailed: true, + worktreeAlreadyRemoved: false, + }, + { + target: targets[2], + statuses: [{ commitsAhead: 0 }], + inspectionFailed: false, + worktreeAlreadyRemoved: false, + }, + ]); + }); + + it('does not inspect a workspace whose worktree is already removed', async () => { + const targets = ['removed', 'existing'].map((workspaceId) => ({ + workspace_id: workspaceId, + archived_at: '2026-06-01T12:00:00Z', + })); + const inspectedWorkspaceIds: string[] = []; + + const results = await inspectArchivedWorkspaceTargets( + targets, + async (workspaceId) => { + inspectedWorkspaceIds.push(workspaceId); + return [{ commitsAhead: 0 }]; + }, + (workspaceId) => workspaceId === 'removed' + ); + + expect(inspectedWorkspaceIds).toEqual(['existing']); + expect(results[0]).toEqual({ + target: targets[0], + statuses: [], + inspectionFailed: false, + worktreeAlreadyRemoved: true, + }); + expect(results[1].worktreeAlreadyRemoved).toBe(false); + }); +}); diff --git a/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts b/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts new file mode 100644 index 00000000000..b235a397acc --- /dev/null +++ b/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts @@ -0,0 +1,167 @@ +import type { BulkDeleteTarget } from 'shared/types'; + +const MAX_INSPECTION_CONCURRENCY = 8; + +export interface ArchivedWorkspaceBulkDeleteSource { + id: string; + name: string; + archivedAt: string | null; + worktreeDeleted?: boolean; + repoCount?: number; +} + +export interface BulkDeleteArchivedWorkspaceDetails { + workspaceName: string; + repoCount?: number; + worktreeDeleted: boolean; +} + +export function sumRepoCounts( + targets: readonly BulkDeleteTarget[], + detailsByWorkspaceId: Readonly< + Record + > +): number | null { + if ( + targets.some( + (target) => + detailsByWorkspaceId[target.workspace_id]?.repoCount === undefined + ) + ) { + return null; + } + + return targets.reduce( + (count, target) => + count + (detailsByWorkspaceId[target.workspace_id]?.repoCount ?? 0), + 0 + ); +} + +export interface BulkDeleteArchivedWorkspaceInspectionTotals { + branchCount: number; + worktreeCount: number; + inspectionFailureCount: number; + worktreeAlreadyRemovedCount: number; +} + +export function resolveDialogTotals({ + initialRepoCount, + initialWorktreeCount, + inspection, +}: { + initialRepoCount: number | null; + initialWorktreeCount: number | null; + inspection: BulkDeleteArchivedWorkspaceInspectionTotals | null; +}): { + branchCount: number | null; + worktreeCount: number | null; +} { + const completeInspection = + inspection !== null && + inspection.inspectionFailureCount === 0 && + inspection.worktreeAlreadyRemovedCount === 0; + + return { + branchCount: + initialRepoCount ?? (completeInspection ? inspection.branchCount : null), + worktreeCount: + initialWorktreeCount ?? + (completeInspection ? inspection.worktreeCount : null), + }; +} + +export function buildArchivedBucketState< + TWorkspace extends ArchivedWorkspaceBulkDeleteSource, +>( + groupWorkspaces: readonly TWorkspace[], + visibleWorkspaceIds?: ReadonlySet +): { + targets: BulkDeleteTarget[]; + detailsByWorkspaceId: Readonly< + Record + >; + visibleWorkspaces: TWorkspace[]; +} { + return { + targets: groupWorkspaces.map((workspace) => ({ + workspace_id: workspace.id, + archived_at: workspace.archivedAt, + })), + detailsByWorkspaceId: Object.fromEntries( + groupWorkspaces.map((workspace) => [ + workspace.id, + { + workspaceName: workspace.name, + repoCount: workspace.repoCount, + worktreeDeleted: workspace.worktreeDeleted === true, + }, + ]) + ), + visibleWorkspaces: visibleWorkspaceIds + ? groupWorkspaces.filter((workspace) => + visibleWorkspaceIds.has(workspace.id) + ) + : [...groupWorkspaces], + }; +} + +export interface ArchivedWorkspaceInspectionResult { + target: BulkDeleteTarget; + statuses: TStatus[]; + inspectionFailed: boolean; + worktreeAlreadyRemoved: boolean; +} + +export async function inspectArchivedWorkspaceTargets( + targets: readonly BulkDeleteTarget[], + inspectWorkspace: (workspaceId: string) => Promise, + isWorktreeAlreadyRemoved: (workspaceId: string) => boolean = () => false +): Promise[]> { + const results: ArchivedWorkspaceInspectionResult[] = new Array( + targets.length + ); + let nextIndex = 0; + + const workers = Array.from( + { + length: Math.min(MAX_INSPECTION_CONCURRENCY, targets.length), + }, + async () => { + while (nextIndex < targets.length) { + const index = nextIndex; + nextIndex += 1; + const target = targets[index]; + + if (isWorktreeAlreadyRemoved(target.workspace_id)) { + results[index] = { + target, + statuses: [], + inspectionFailed: false, + worktreeAlreadyRemoved: true, + }; + continue; + } + + try { + results[index] = { + target, + statuses: await inspectWorkspace(target.workspace_id), + inspectionFailed: false, + worktreeAlreadyRemoved: false, + }; + } catch { + results[index] = { + target, + statuses: [], + inspectionFailed: true, + worktreeAlreadyRemoved: false, + }; + } + } + } + ); + + await Promise.all(workers); + return results; +} diff --git a/packages/web-core/src/i18n/locales/en/common.json b/packages/web-core/src/i18n/locales/en/common.json index f1198c3daaa..076455411bd 100644 --- a/packages/web-core/src/i18n/locales/en/common.json +++ b/packages/web-core/src/i18n/locales/en/common.json @@ -213,6 +213,19 @@ "unpin": "Unpin", "archive": "Archive", "more": "More actions", + "toggleLayout": "Toggle workspace layout", + "archiveBucketToday": "Today", + "archiveBucketOneToThreeDays": "1–3 days", + "archiveBucketThreeToSevenDays": "3–7 days", + "archiveBucketSevenToFifteenDays": "7–15 days", + "archiveBucketFifteenToThirtyDays": "15–30 days", + "archiveBucketOlderThanThirtyDays": "Older than 30 days", + "archiveBucketActions": "Actions for {{bucket}}", + "archiveBucketActionsFiltered": "Clear active filters to remove archived workspaces in bulk", + "removeArchiveBucket": "Remove all in this bucket", + "archivedRecentlyCount": "Archived recently ({{count}})", + "restoreArchivedWorkspace": "Restore {{workspace}}", + "restoreArchivedWorkspaceFailed": "Could not restore {{workspace}}. Try again.", "rename": { "title": "Rename Workspace", "description": "Enter a new name for this workspace.", @@ -496,7 +509,27 @@ "prFilterHasPr": "Has PR", "prFilterNoPr": "No PR", "sortButtonTitle": "Sort workspaces", - "filterButtonTitle": "Filter workspaces" + "filterButtonTitle": "Filter workspaces", + "bulkDeleteTitle": "Remove archived workspaces", + "bulkDeleteResultsTitle": "Removal results", + "bulkDeleteResultsDescription": "Each workspace is reported separately so skipped or failed removals are visible.", + "bulkDeleteDescription": "This permanently removes the workspaces listed below from “{{bucket}}” if their archive state is unchanged. This action cannot be undone.", + "bulkDeleteResultSummary": "{{deleted}} removed, {{skipped}} skipped, {{failed}} failed", + "bulkDeleteWorkspaces": "Workspaces", + "bulkDeleteBranches": "Branches deleted", + "bulkDeleteWorktrees": "Worktrees removed", + "bulkDeleteUnmerged": "Branches with unmerged commits", + "bulkDeleteUnknownComparison": "Branches that could not be compared", + "bulkDeleteInspecting": "Inspecting…", + "bulkDeleteUnmergedWorkspaces": "across {{count}} workspace(s)", + "bulkDeleteInspectionFailed": "Could not inspect every branch.", + "bulkDeleteInspectionFailures": "Workspaces whose unmerged status is unknown", + "bulkDeleteWorktreeAlreadyRemoved": "Unmerged status unknown, worktree already removed", + "bulkDeleteRequestFailed": "The bulk removal request failed.", + "bulkDeleteRetry": "Retry", + "bulkDeleteTargets": "Workspaces that will be removed", + "bulkDeleteRemoving": "Removing…", + "bulkDeleteConfirm": "Remove {{count}} workspaces" }, "sortAscending": "Ascending", "sortDescending": "Descending", diff --git a/packages/web-core/src/i18n/locales/es/common.json b/packages/web-core/src/i18n/locales/es/common.json index f04f74c00fb..0d7035f1d3c 100644 --- a/packages/web-core/src/i18n/locales/es/common.json +++ b/packages/web-core/src/i18n/locales/es/common.json @@ -199,6 +199,19 @@ "unpin": "Desfijar", "archive": "Archivar", "more": "Más acciones", + "toggleLayout": "Cambiar diseño de los espacios de trabajo", + "archiveBucketToday": "Hoy", + "archiveBucketOneToThreeDays": "1–3 días", + "archiveBucketThreeToSevenDays": "3–7 días", + "archiveBucketSevenToFifteenDays": "7–15 días", + "archiveBucketFifteenToThirtyDays": "15–30 días", + "archiveBucketOlderThanThirtyDays": "Más de 30 días", + "archiveBucketActions": "Acciones para {{bucket}}", + "archiveBucketActionsFiltered": "Borra los filtros activos para eliminar espacios de trabajo archivados en bloque", + "removeArchiveBucket": "Eliminar todos los de este grupo", + "archivedRecentlyCount": "Archivados recientemente ({{count}})", + "restoreArchivedWorkspace": "Restaurar {{workspace}}", + "restoreArchivedWorkspaceFailed": "No se pudo restaurar {{workspace}}. Inténtalo de nuevo.", "rename": { "title": "Renombrar espacio de trabajo", "description": "Ingresa un nuevo nombre para este espacio de trabajo.", @@ -482,7 +495,27 @@ "prFilterHasPr": "Has PR", "prFilterNoPr": "No PR", "sortButtonTitle": "Sort workspaces", - "filterButtonTitle": "Filter workspaces" + "filterButtonTitle": "Filter workspaces", + "bulkDeleteTitle": "Eliminar espacios de trabajo archivados", + "bulkDeleteResultsTitle": "Resultados de eliminación", + "bulkDeleteResultsDescription": "Cada espacio de trabajo se informa por separado para mostrar las eliminaciones omitidas o fallidas.", + "bulkDeleteDescription": "Esto elimina permanentemente los espacios de trabajo enumerados a continuación de «{{bucket}}» si su estado de archivado no ha cambiado. Esta acción no se puede deshacer.", + "bulkDeleteResultSummary": "{{deleted}} eliminados, {{skipped}} omitidos, {{failed}} fallidos", + "bulkDeleteWorkspaces": "Espacios de trabajo", + "bulkDeleteBranches": "Ramas que se eliminarán", + "bulkDeleteWorktrees": "Árboles de trabajo que se eliminarán", + "bulkDeleteUnmerged": "Ramas con commits sin fusionar", + "bulkDeleteUnknownComparison": "Ramas que no se pudieron comparar", + "bulkDeleteInspecting": "Inspeccionando…", + "bulkDeleteUnmergedWorkspaces": "en {{count}} espacio(s) de trabajo", + "bulkDeleteInspectionFailed": "No se pudieron inspeccionar todas las ramas.", + "bulkDeleteInspectionFailures": "Espacios de trabajo cuyo estado de commits sin fusionar se desconoce", + "bulkDeleteWorktreeAlreadyRemoved": "Estado sin fusionar desconocido; árbol de trabajo ya eliminado", + "bulkDeleteRequestFailed": "La solicitud de eliminación masiva falló.", + "bulkDeleteRetry": "Reintentar", + "bulkDeleteTargets": "Espacios de trabajo que se eliminarán", + "bulkDeleteRemoving": "Eliminando…", + "bulkDeleteConfirm": "Eliminar {{count}} espacios de trabajo" }, "sortAscending": "Ascending", "sortDescending": "Descending", diff --git a/packages/web-core/src/i18n/locales/fr/common.json b/packages/web-core/src/i18n/locales/fr/common.json index 5d0edf3aa06..882efda5867 100644 --- a/packages/web-core/src/i18n/locales/fr/common.json +++ b/packages/web-core/src/i18n/locales/fr/common.json @@ -199,6 +199,19 @@ "unpin": "Désépingler", "archive": "Archiver", "more": "Plus d'actions", + "toggleLayout": "Changer la disposition des espaces de travail", + "archiveBucketToday": "Aujourd’hui", + "archiveBucketOneToThreeDays": "1–3 jours", + "archiveBucketThreeToSevenDays": "3–7 jours", + "archiveBucketSevenToFifteenDays": "7–15 jours", + "archiveBucketFifteenToThirtyDays": "15–30 jours", + "archiveBucketOlderThanThirtyDays": "Plus de 30 jours", + "archiveBucketActions": "Actions pour {{bucket}}", + "archiveBucketActionsFiltered": "Effacez les filtres actifs pour supprimer des espaces de travail archivés en bloc", + "removeArchiveBucket": "Tout supprimer dans ce groupe", + "archivedRecentlyCount": "Archivés récemment ({{count}})", + "restoreArchivedWorkspace": "Restaurer {{workspace}}", + "restoreArchivedWorkspaceFailed": "Impossible de restaurer {{workspace}}. Réessayez.", "rename": { "title": "Renommer l'espace de travail", "description": "Saisissez un nouveau nom pour cet espace de travail.", @@ -482,7 +495,27 @@ "prFilterHasPr": "Has PR", "prFilterNoPr": "No PR", "sortButtonTitle": "Sort workspaces", - "filterButtonTitle": "Filter workspaces" + "filterButtonTitle": "Filter workspaces", + "bulkDeleteTitle": "Supprimer les espaces de travail archivés", + "bulkDeleteResultsTitle": "Résultats de la suppression", + "bulkDeleteResultsDescription": "Chaque espace de travail est signalé séparément afin que les suppressions ignorées ou échouées restent visibles.", + "bulkDeleteDescription": "Cette action supprime définitivement les espaces de travail listés ci-dessous de « {{bucket}} » si leur état d’archivage n’a pas changé. Elle est irréversible.", + "bulkDeleteResultSummary": "{{deleted}} supprimés, {{skipped}} ignorés, {{failed}} échoués", + "bulkDeleteWorkspaces": "Espaces de travail", + "bulkDeleteBranches": "Branches supprimées", + "bulkDeleteWorktrees": "Arborescences de travail supprimées", + "bulkDeleteUnmerged": "Branches avec des commits non fusionnés", + "bulkDeleteUnknownComparison": "Branches qui n’ont pas pu être comparées", + "bulkDeleteInspecting": "Analyse…", + "bulkDeleteUnmergedWorkspaces": "dans {{count}} espace(s) de travail", + "bulkDeleteInspectionFailed": "Impossible d’analyser toutes les branches.", + "bulkDeleteInspectionFailures": "Espaces de travail dont l’état de fusion est inconnu", + "bulkDeleteWorktreeAlreadyRemoved": "État non fusionné inconnu, arborescence déjà supprimée", + "bulkDeleteRequestFailed": "La demande de suppression groupée a échoué.", + "bulkDeleteRetry": "Réessayer", + "bulkDeleteTargets": "Espaces de travail qui seront supprimés", + "bulkDeleteRemoving": "Suppression…", + "bulkDeleteConfirm": "Supprimer {{count}} espaces de travail" }, "sortAscending": "Ascending", "sortDescending": "Descending", diff --git a/packages/web-core/src/i18n/locales/ja/common.json b/packages/web-core/src/i18n/locales/ja/common.json index 9fb1313b622..828a6444c2c 100644 --- a/packages/web-core/src/i18n/locales/ja/common.json +++ b/packages/web-core/src/i18n/locales/ja/common.json @@ -199,6 +199,19 @@ "unpin": "ピン留め解除", "archive": "アーカイブ", "more": "その他の操作", + "toggleLayout": "ワークスペースの表示を切り替え", + "archiveBucketToday": "今日", + "archiveBucketOneToThreeDays": "1~3日前", + "archiveBucketThreeToSevenDays": "3~7日前", + "archiveBucketSevenToFifteenDays": "7~15日前", + "archiveBucketFifteenToThirtyDays": "15~30日前", + "archiveBucketOlderThanThirtyDays": "30日より前", + "archiveBucketActions": "{{bucket}}の操作", + "archiveBucketActionsFiltered": "アクティブなフィルターを解除して、アーカイブ済みワークスペースを一括削除します", + "removeArchiveBucket": "このグループをすべて削除", + "archivedRecentlyCount": "最近アーカイブ ({{count}})", + "restoreArchivedWorkspace": "{{workspace}}を復元", + "restoreArchivedWorkspaceFailed": "{{workspace}}を復元できませんでした。もう一度お試しください。", "rename": { "title": "ワークスペースの名前を変更", "description": "このワークスペースの新しい名前を入力してください。", @@ -482,7 +495,27 @@ "prFilterHasPr": "Has PR", "prFilterNoPr": "No PR", "sortButtonTitle": "Sort workspaces", - "filterButtonTitle": "Filter workspaces" + "filterButtonTitle": "Filter workspaces", + "bulkDeleteTitle": "アーカイブ済みワークスペースを削除", + "bulkDeleteResultsTitle": "削除結果", + "bulkDeleteResultsDescription": "スキップまたは失敗した削除を確認できるよう、ワークスペースごとに結果を表示します。", + "bulkDeleteDescription": "アーカイブ状態が変更されていない場合、以下の「{{bucket}}」のワークスペースを完全に削除します。この操作は元に戻せません。", + "bulkDeleteResultSummary": "削除 {{deleted}}件、スキップ {{skipped}}件、失敗 {{failed}}件", + "bulkDeleteWorkspaces": "ワークスペース", + "bulkDeleteBranches": "削除されるブランチ", + "bulkDeleteWorktrees": "削除されるワークツリー", + "bulkDeleteUnmerged": "未マージコミットがあるブランチ", + "bulkDeleteUnknownComparison": "比較できなかったブランチ", + "bulkDeleteInspecting": "確認中…", + "bulkDeleteUnmergedWorkspaces": "{{count}}件のワークスペース", + "bulkDeleteInspectionFailed": "すべてのブランチを確認できませんでした。", + "bulkDeleteInspectionFailures": "未マージ状態を確認できないワークスペース", + "bulkDeleteWorktreeAlreadyRemoved": "未マージ状態は不明(ワークツリーは削除済み)", + "bulkDeleteRequestFailed": "一括削除リクエストに失敗しました。", + "bulkDeleteRetry": "再試行", + "bulkDeleteTargets": "削除されるワークスペース", + "bulkDeleteRemoving": "削除中…", + "bulkDeleteConfirm": "{{count}}件のワークスペースを削除" }, "sortAscending": "Ascending", "sortDescending": "Descending", diff --git a/packages/web-core/src/i18n/locales/ko/common.json b/packages/web-core/src/i18n/locales/ko/common.json index a33e31fbb93..96480bc84b4 100644 --- a/packages/web-core/src/i18n/locales/ko/common.json +++ b/packages/web-core/src/i18n/locales/ko/common.json @@ -199,6 +199,19 @@ "unpin": "고정 해제", "archive": "보관", "more": "더 많은 작업", + "toggleLayout": "워크스페이스 레이아웃 전환", + "archiveBucketToday": "오늘", + "archiveBucketOneToThreeDays": "1~3일", + "archiveBucketThreeToSevenDays": "3~7일", + "archiveBucketSevenToFifteenDays": "7~15일", + "archiveBucketFifteenToThirtyDays": "15~30일", + "archiveBucketOlderThanThirtyDays": "30일 초과", + "archiveBucketActions": "{{bucket}} 작업", + "archiveBucketActionsFiltered": "보관된 워크스페이스를 일괄 삭제하려면 활성 필터를 해제하세요", + "removeArchiveBucket": "이 구간 모두 삭제", + "archivedRecentlyCount": "최근 보관됨 ({{count}})", + "restoreArchivedWorkspace": "{{workspace}} 복원", + "restoreArchivedWorkspaceFailed": "{{workspace}}을(를) 복원하지 못했습니다. 다시 시도하세요.", "rename": { "title": "워크스페이스 이름 변경", "description": "이 워크스페이스의 새 이름을 입력하세요.", @@ -482,7 +495,27 @@ "prFilterHasPr": "Has PR", "prFilterNoPr": "No PR", "sortButtonTitle": "Sort workspaces", - "filterButtonTitle": "Filter workspaces" + "filterButtonTitle": "Filter workspaces", + "bulkDeleteTitle": "보관된 워크스페이스 삭제", + "bulkDeleteResultsTitle": "삭제 결과", + "bulkDeleteResultsDescription": "건너뛰거나 실패한 삭제를 확인할 수 있도록 워크스페이스별 결과를 표시합니다.", + "bulkDeleteDescription": "보관 상태가 변경되지 않은 경우 아래 나열된 “{{bucket}}” 워크스페이스를 영구 삭제합니다. 이 작업은 되돌릴 수 없습니다.", + "bulkDeleteResultSummary": "{{deleted}}개 삭제, {{skipped}}개 건너뜀, {{failed}}개 실패", + "bulkDeleteWorkspaces": "워크스페이스", + "bulkDeleteBranches": "삭제할 브랜치", + "bulkDeleteWorktrees": "제거할 워크트리", + "bulkDeleteUnmerged": "병합되지 않은 커밋이 있는 브랜치", + "bulkDeleteUnknownComparison": "비교할 수 없는 브랜치", + "bulkDeleteInspecting": "검사 중…", + "bulkDeleteUnmergedWorkspaces": "{{count}}개 워크스페이스", + "bulkDeleteInspectionFailed": "모든 브랜치를 검사하지 못했습니다.", + "bulkDeleteInspectionFailures": "병합되지 않은 상태를 알 수 없는 워크스페이스", + "bulkDeleteWorktreeAlreadyRemoved": "병합되지 않은 상태를 알 수 없음, 워크트리는 이미 제거됨", + "bulkDeleteRequestFailed": "대량 삭제 요청에 실패했습니다.", + "bulkDeleteRetry": "다시 시도", + "bulkDeleteTargets": "삭제될 워크스페이스", + "bulkDeleteRemoving": "삭제 중…", + "bulkDeleteConfirm": "워크스페이스 {{count}}개 삭제" }, "sortAscending": "Ascending", "sortDescending": "Descending", diff --git a/packages/web-core/src/i18n/locales/zh-Hans/common.json b/packages/web-core/src/i18n/locales/zh-Hans/common.json index d12e4454d1e..cd9bc7f20b8 100644 --- a/packages/web-core/src/i18n/locales/zh-Hans/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hans/common.json @@ -199,6 +199,19 @@ "unpin": "取消置顶", "archive": "归档", "more": "更多操作", + "toggleLayout": "切换工作区布局", + "archiveBucketToday": "今天", + "archiveBucketOneToThreeDays": "1–3 天", + "archiveBucketThreeToSevenDays": "3–7 天", + "archiveBucketSevenToFifteenDays": "7–15 天", + "archiveBucketFifteenToThirtyDays": "15–30 天", + "archiveBucketOlderThanThirtyDays": "30 天以上", + "archiveBucketActions": "{{bucket}}操作", + "archiveBucketActionsFiltered": "清除当前筛选条件后才能批量删除已归档工作区", + "removeArchiveBucket": "删除此分组中的全部工作区", + "archivedRecentlyCount": "最近归档 ({{count}})", + "restoreArchivedWorkspace": "恢复 {{workspace}}", + "restoreArchivedWorkspaceFailed": "无法恢复 {{workspace}}。请重试。", "rename": { "title": "重命名工作区", "description": "输入此工作区的新名称。", @@ -482,7 +495,27 @@ "prFilterHasPr": "Has PR", "prFilterNoPr": "No PR", "sortButtonTitle": "Sort workspaces", - "filterButtonTitle": "Filter workspaces" + "filterButtonTitle": "Filter workspaces", + "bulkDeleteTitle": "删除已归档工作区", + "bulkDeleteResultsTitle": "删除结果", + "bulkDeleteResultsDescription": "逐个显示工作区结果,以便查看跳过或失败的删除。", + "bulkDeleteDescription": "如果归档状态未发生变化,这会永久删除下方列出的“{{bucket}}”工作区。此操作无法撤销。", + "bulkDeleteResultSummary": "已删除 {{deleted}} 个,已跳过 {{skipped}} 个,失败 {{failed}} 个", + "bulkDeleteWorkspaces": "工作区", + "bulkDeleteBranches": "将删除的分支", + "bulkDeleteWorktrees": "将移除的工作树", + "bulkDeleteUnmerged": "包含未合并提交的分支", + "bulkDeleteUnknownComparison": "无法比较的分支", + "bulkDeleteInspecting": "正在检查…", + "bulkDeleteUnmergedWorkspaces": "涉及 {{count}} 个工作区", + "bulkDeleteInspectionFailed": "无法检查所有分支。", + "bulkDeleteInspectionFailures": "无法确定未合并状态的工作区", + "bulkDeleteWorktreeAlreadyRemoved": "未合并状态未知,工作树已移除", + "bulkDeleteRequestFailed": "批量删除请求失败。", + "bulkDeleteRetry": "重试", + "bulkDeleteTargets": "将删除的工作区", + "bulkDeleteRemoving": "正在删除…", + "bulkDeleteConfirm": "删除 {{count}} 个工作区" }, "sortAscending": "Ascending", "sortDescending": "Descending", diff --git a/packages/web-core/src/i18n/locales/zh-Hant/common.json b/packages/web-core/src/i18n/locales/zh-Hant/common.json index e699141bdef..2cc06255a35 100644 --- a/packages/web-core/src/i18n/locales/zh-Hant/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hant/common.json @@ -199,6 +199,19 @@ "unpin": "取消釘選", "archive": "封存", "more": "更多操作", + "toggleLayout": "切換工作區版面", + "archiveBucketToday": "今天", + "archiveBucketOneToThreeDays": "1–3 天", + "archiveBucketThreeToSevenDays": "3–7 天", + "archiveBucketSevenToFifteenDays": "7–15 天", + "archiveBucketFifteenToThirtyDays": "15–30 天", + "archiveBucketOlderThanThirtyDays": "30 天以上", + "archiveBucketActions": "{{bucket}}操作", + "archiveBucketActionsFiltered": "清除目前的篩選條件後才能批次刪除已封存的工作區", + "removeArchiveBucket": "刪除此分組中的全部工作區", + "archivedRecentlyCount": "最近封存 ({{count}})", + "restoreArchivedWorkspace": "還原 {{workspace}}", + "restoreArchivedWorkspaceFailed": "無法還原 {{workspace}}。請再試一次。", "rename": { "title": "重新命名工作區", "description": "輸入此工作區的新名稱。", @@ -482,7 +495,27 @@ "prFilterHasPr": "Has PR", "prFilterNoPr": "No PR", "sortButtonTitle": "Sort workspaces", - "filterButtonTitle": "Filter workspaces" + "filterButtonTitle": "Filter workspaces", + "bulkDeleteTitle": "刪除已封存工作區", + "bulkDeleteResultsTitle": "刪除結果", + "bulkDeleteResultsDescription": "逐一顯示工作區結果,以便查看略過或失敗的刪除。", + "bulkDeleteDescription": "如果封存狀態未變更,這會永久刪除下方列出的「{{bucket}}」工作區。此操作無法復原。", + "bulkDeleteResultSummary": "已刪除 {{deleted}} 個,已略過 {{skipped}} 個,失敗 {{failed}} 個", + "bulkDeleteWorkspaces": "工作區", + "bulkDeleteBranches": "將刪除的分支", + "bulkDeleteWorktrees": "將移除的工作樹", + "bulkDeleteUnmerged": "包含未合併提交的分支", + "bulkDeleteUnknownComparison": "無法比較的分支", + "bulkDeleteInspecting": "正在檢查…", + "bulkDeleteUnmergedWorkspaces": "涉及 {{count}} 個工作區", + "bulkDeleteInspectionFailed": "無法檢查所有分支。", + "bulkDeleteInspectionFailures": "無法確定未合併狀態的工作區", + "bulkDeleteWorktreeAlreadyRemoved": "未合併狀態不明,工作樹已移除", + "bulkDeleteRequestFailed": "批次刪除請求失敗。", + "bulkDeleteRetry": "重試", + "bulkDeleteTargets": "將刪除的工作區", + "bulkDeleteRemoving": "正在刪除…", + "bulkDeleteConfirm": "刪除 {{count}} 個工作區" }, "sortAscending": "Ascending", "sortDescending": "Descending", diff --git a/packages/web-core/src/pages/workspaces/WorkspacesSidebarContainer.tsx b/packages/web-core/src/pages/workspaces/WorkspacesSidebarContainer.tsx index 81a2e6cc4b7..4df3ee2feee 100644 --- a/packages/web-core/src/pages/workspaces/WorkspacesSidebarContainer.tsx +++ b/packages/web-core/src/pages/workspaces/WorkspacesSidebarContainer.tsx @@ -3,7 +3,11 @@ import { useParams } from '@tanstack/react-router'; import { useTranslation } from 'react-i18next'; import { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext'; import { useScratch } from '@/shared/hooks/useScratch'; -import { ScratchType, type DraftWorkspaceData } from 'shared/types'; +import { + ScratchType, + type BulkDeleteTarget, + type DraftWorkspaceData, +} from 'shared/types'; import { splitMessageToTitleDescription } from '@/shared/lib/string'; import { cn } from '@/shared/lib/utils'; import { useIsMobile } from '@/shared/hooks/useIsMobile'; @@ -22,6 +26,10 @@ import { WorkspacesSidebar, type WorkspacesSidebarPersistKeys, } from '@vibe/ui/components/WorkspacesSidebar'; +import type { + BulkDeleteDialogBranchStatus, + BulkDeleteDialogItemResult, +} from '@vibe/ui/components/BulkDeleteArchivedWorkspacesDialog'; import { PropertyDropdown } from '@vibe/ui/components/PropertyDropdown'; import { PrimaryButton } from '@vibe/ui/components/PrimaryButton'; import { IconButton } from '@vibe/ui/components/IconButton'; @@ -44,6 +52,10 @@ import { XIcon, } from '@phosphor-icons/react'; import { useRemoteCloudHostsAppBarModel } from '@/shared/hooks/useRemoteCloudHosts'; +import { workspacesApi } from '@/shared/lib/api'; +import { isArchivedRecently } from '@/shared/lib/archiveBuckets'; +import { useActions } from '@/shared/hooks/useActions'; +import { Actions } from '@/shared/actions'; export type WorkspaceLayoutMode = 'flat' | 'accordion'; @@ -257,6 +269,7 @@ export function WorkspacesSidebarContainer({ const [isSortDialogOpen, setIsSortDialogOpen] = useState(false); const [isFilterDialogOpen, setIsFilterDialogOpen] = useState(false); const { t } = useTranslation('common'); + const { executeAction } = useActions(); const sortDialogTitle = t('kanban.workspaceSidebar.sortButtonTitle'); const filterDialogTitle = t('kanban.workspaceSidebar.filterButtonTitle'); @@ -381,6 +394,23 @@ export function WorkspacesSidebarContainer({ [filteredArchivedWorkspaces, sortWorkspaces] ); + const allSortedArchivedWorkspaces = useMemo( + () => sortWorkspaces(archivedWorkspaces), + [archivedWorkspaces, sortWorkspaces] + ); + + const recentlyArchivedWorkspaces = useMemo(() => { + const nowMilliseconds = Date.now(); + return archivedWorkspaces + .filter((workspace) => + isArchivedRecently(workspace.archivedAt, nowMilliseconds) + ) + .sort( + (a, b) => + Date.parse(b.archivedAt ?? '') - Date.parse(a.archivedAt ?? '') + ); + }, [archivedWorkspaces]); + // Apply pagination (only when not searching) const paginatedActiveWorkspaces = useMemo( () => @@ -398,6 +428,11 @@ export function WorkspacesSidebarContainer({ [sortedArchivedWorkspaces, displayLimit, isSearching] ); + const visibleArchivedWorkspaceIds = useMemo( + () => new Set(paginatedArchivedWorkspaces.map((workspace) => workspace.id)), + [paginatedArchivedWorkspaces] + ); + // Check if there are more workspaces to load const hasMoreWorkspaces = showArchive ? sortedArchivedWorkspaces.length > displayLimit @@ -465,6 +500,40 @@ export function WorkspacesSidebarContainer({ }); }, []); + const inspectArchivedWorkspace = useCallback( + async (workspaceId: string): Promise => { + const statuses = await workspacesApi.getBranchStatus(workspaceId); + return statuses.map((status) => ({ + commitsAhead: status.commits_ahead, + })); + }, + [] + ); + + const bulkDeleteArchivedBucket = useCallback( + async ( + targets: BulkDeleteTarget[] + ): Promise => { + const response = await workspacesApi.bulkDeleteArchived({ + targets, + delete_branches: true, + }); + return response.results.map((result) => ({ + workspaceId: result.workspace_id, + workspaceName: result.workspace_name, + outcome: result.outcome, + })); + }, + [] + ); + + const restoreArchivedWorkspace = useCallback( + async (workspaceId: string) => { + await executeAction(Actions.ArchiveWorkspace, workspaceId); + }, + [executeAction] + ); + const sidebarPersistKeys: WorkspacesSidebarPersistKeys = { raisedHand: PERSIST_KEYS.workspacesSidebarRaisedHand, notRunning: PERSIST_KEYS.workspacesSidebarNotRunning, @@ -540,12 +609,15 @@ export function WorkspacesSidebarContainer({ (response); }, + bulkDeleteArchived: async ( + data: BulkDeleteArchivedWorkspacesRequest + ): Promise => { + const response = await makeRequest('/api/workspaces/archived/bulk-delete', { + method: 'POST', + body: JSON.stringify(data), + }); + return handleApiResponse(response); + }, + linkToIssue: async ( workspaceId: string, projectId: string, diff --git a/packages/web-core/src/shared/lib/archiveBuckets.test.ts b/packages/web-core/src/shared/lib/archiveBuckets.test.ts new file mode 100644 index 00000000000..12de34309d3 --- /dev/null +++ b/packages/web-core/src/shared/lib/archiveBuckets.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { + DAY_IN_MILLISECONDS, + archiveBucketForTimestamp, + archiveBucketFromAgeMilliseconds, + isArchivedRecently, +} from './archiveBuckets'; + +const millisecond = 1; + +describe('archiveBucketFromAgeMilliseconds', () => { + it.each([ + [0, 'today'], + [millisecond, 'today'], + [DAY_IN_MILLISECONDS - millisecond, 'today'], + [DAY_IN_MILLISECONDS, 'one_to_three_days'], + [DAY_IN_MILLISECONDS + millisecond, 'one_to_three_days'], + [3 * DAY_IN_MILLISECONDS - millisecond, 'one_to_three_days'], + [3 * DAY_IN_MILLISECONDS, 'three_to_seven_days'], + [3 * DAY_IN_MILLISECONDS + millisecond, 'three_to_seven_days'], + [7 * DAY_IN_MILLISECONDS - millisecond, 'three_to_seven_days'], + [7 * DAY_IN_MILLISECONDS, 'seven_to_fifteen_days'], + [7 * DAY_IN_MILLISECONDS + millisecond, 'seven_to_fifteen_days'], + [15 * DAY_IN_MILLISECONDS - millisecond, 'seven_to_fifteen_days'], + [15 * DAY_IN_MILLISECONDS, 'fifteen_to_thirty_days'], + [15 * DAY_IN_MILLISECONDS + millisecond, 'fifteen_to_thirty_days'], + [30 * DAY_IN_MILLISECONDS - millisecond, 'fifteen_to_thirty_days'], + [30 * DAY_IN_MILLISECONDS, 'older_than_thirty_days'], + [30 * DAY_IN_MILLISECONDS + millisecond, 'older_than_thirty_days'], + ] as const)('maps age %i ms to %s', (age, expected) => { + expect(archiveBucketFromAgeMilliseconds(age)).toBe(expected); + }); + + it('clamps future timestamps to today', () => { + expect(archiveBucketFromAgeMilliseconds(-10 * DAY_IN_MILLISECONDS)).toBe( + 'today' + ); + }); +}); + +describe('archiveBucketForTimestamp', () => { + const now = Date.UTC(2026, 6, 22, 12); + + it('puts missing or invalid timestamps in the defensive oldest bucket', () => { + expect(archiveBucketForTimestamp(null, now)).toBe('older_than_thirty_days'); + expect(archiveBucketForTimestamp('not-a-date', now)).toBe( + 'older_than_thirty_days' + ); + }); + + it('uses the Today plus 1–3 day buckets for Archived recently', () => { + expect( + isArchivedRecently( + new Date(now - 3 * DAY_IN_MILLISECONDS + millisecond).toISOString(), + now + ) + ).toBe(true); + expect( + isArchivedRecently( + new Date(now - 3 * DAY_IN_MILLISECONDS).toISOString(), + now + ) + ).toBe(false); + }); +}); diff --git a/packages/web-core/src/shared/lib/archiveBuckets.ts b/packages/web-core/src/shared/lib/archiveBuckets.ts new file mode 100644 index 00000000000..3b49f73a70d --- /dev/null +++ b/packages/web-core/src/shared/lib/archiveBuckets.ts @@ -0,0 +1,9 @@ +// The UI package owns the pure implementation because the presentational +// sidebar performs the grouping. Re-export it here for application callers. +export { + ARCHIVE_BUCKET_ORDER, + DAY_IN_MILLISECONDS, + archiveBucketForTimestamp, + archiveBucketFromAgeMilliseconds, + isArchivedRecently, +} from '@vibe/ui/lib/archiveBuckets'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 046f671f35b..df30ebd0dbc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,9 @@ importers: vite: specifier: ^7.3.1 version: 7.3.1(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.0) + vitest: + specifier: 3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.0) packages/local-web: dependencies: @@ -1465,6 +1468,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} @@ -2586,6 +2592,9 @@ packages: '@types/babel__traverse@7.20.7': resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -2682,6 +2691,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -2875,6 +2887,38 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0 + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vue/reactivity@3.5.18': resolution: {integrity: sha512-x0vPO5Imw+3sChLM5Y+B6G1zPjwdOri9e8V21NnTnlEvkxatHEH5B5KEAJcjuzQ7BsjGrKtfzuQ5eQwXh8HXBg==} @@ -2968,6 +3012,10 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} @@ -3019,6 +3067,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -3033,6 +3085,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3049,6 +3105,10 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chevrotain-allstar@0.3.1: resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} peerDependencies: @@ -3345,6 +3405,10 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -3419,6 +3483,9 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -3543,10 +3610,17 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -3876,6 +3950,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -3979,6 +4056,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lowlight@3.3.0: resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} @@ -4006,6 +4086,9 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@0.30.8: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} engines: {node: '>=12'} @@ -4307,6 +4390,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4693,6 +4780,9 @@ packages: shiki@3.21.0: resolution: {integrity: sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -4726,6 +4816,12 @@ packages: spawn-command@0.0.2: resolution: {integrity: sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -4749,6 +4845,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + style-mod@4.1.3: resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} @@ -4823,6 +4922,12 @@ packages: tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -4831,6 +4936,18 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -5002,6 +5119,11 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5042,6 +5164,34 @@ packages: yaml: optional: true + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -5099,6 +5249,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -5206,7 +5361,7 @@ snapshots: '@babel/traverse': 7.27.4 '@babel/types': 7.27.6 convert-source-map: 2.0.0 - debug: 4.4.1 + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -5370,7 +5525,7 @@ snapshots: '@babel/parser': 7.27.5 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.1 + debug: 4.4.3 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -5758,6 +5913,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -7042,6 +7199,11 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} '@types/d3-axis@3.0.6': @@ -7163,6 +7325,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -7267,7 +7431,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.2) '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.2) - debug: 4.4.1 + debug: 4.4.3 eslint: 8.57.1 ts-api-utils: 1.4.3(typescript@5.9.2) optionalDependencies: @@ -7283,7 +7447,7 @@ snapshots: dependencies: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.4.1 + debug: 4.4.3 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 @@ -7401,6 +7565,52 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.0) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + '@vue/reactivity@3.5.18': dependencies: '@vue/shared': 3.5.18 @@ -7478,6 +7688,8 @@ snapshots: array-union@2.1.0: {} + assertion-error@2.0.1: {} + ast-types@0.16.1: dependencies: tslib: 2.8.1 @@ -7540,6 +7752,8 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.0) + cac@6.7.14: {} + callsites@3.1.0: {} camelcase-css@2.0.1: {} @@ -7548,6 +7762,14 @@ snapshots: ccount@2.0.1: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -7561,6 +7783,8 @@ snapshots: character-reference-invalid@2.0.1: {} + check-error@2.1.3: {} + chevrotain-allstar@0.3.1(chevrotain@11.1.2): dependencies: chevrotain: 11.1.2 @@ -7908,6 +8132,8 @@ snapshots: dependencies: character-entities: 2.0.2 + deep-eql@5.0.2: {} + deep-is@0.1.4: {} delaunator@5.0.1: @@ -7963,6 +8189,8 @@ snapshots: entities@6.0.1: {} + es-module-lexer@1.7.0: {} + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -8126,8 +8354,14 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} + expect-type@1.4.0: {} + extend@3.0.2: {} fancy-ansi@0.1.3: @@ -8494,6 +8728,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -8579,6 +8815,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lowlight@3.3.0: dependencies: '@types/hast': 3.0.4 @@ -8605,6 +8843,10 @@ snapshots: dependencies: react: 18.3.1 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + magic-string@0.30.8: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 @@ -9121,6 +9363,8 @@ snapshots: pathe@2.0.3: {} + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -9528,6 +9772,8 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + siginfo@2.0.0: {} + signal-exit@4.1.0: {} simple-icons@15.18.0: {} @@ -9546,6 +9792,10 @@ snapshots: spawn-command@0.0.2: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -9573,6 +9823,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + style-mod@4.1.3: {} style-to-js@1.1.21: @@ -9664,6 +9918,10 @@ snapshots: tiny-warning@1.0.3: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -9671,6 +9929,12 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -9842,6 +10106,27 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@3.2.4(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite@7.3.1(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.0): dependencies: esbuild: 0.27.2 @@ -9872,6 +10157,48 @@ snapshots: tsx: 4.21.0 yaml: 2.8.0 + vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.0) + vite-node: 3.2.4(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 20.19.37 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + void-elements@3.1.0: {} vscode-jsonrpc@8.2.0: {} @@ -9916,6 +10243,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrap-ansi@7.0.0: diff --git a/shared/types.ts b/shared/types.ts index 18708b7222f..42aa9770816 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -164,7 +164,7 @@ export type CreateScratch = { payload: ScratchPayload, }; export type UpdateScratch = { payload: ScratchPayload, }; -export type Workspace = { id: string, task_id: string | null, container_ref: string | null, branch: string, setup_completed_at: string | null, created_at: string, updated_at: string, archived: boolean, pinned: boolean, name: string | null, worktree_deleted: boolean, }; +export type Workspace = { id: string, task_id: string | null, container_ref: string | null, branch: string, setup_completed_at: string | null, created_at: string, updated_at: string, archived: boolean, archived_at: string | null, pinned: boolean, name: string | null, worktree_deleted: boolean, }; export type WorkspaceWithStatus = { /** @@ -183,7 +183,9 @@ is_executor_running: boolean, * scripts). Lets the CLI gate phrase itself correctly: "claude is * working in chat" vs "preparing the workspace". */ -is_coding_agent_running: boolean, is_errored: boolean, id: string, task_id: string | null, container_ref: string | null, branch: string, setup_completed_at: string | null, created_at: string, updated_at: string, archived: boolean, pinned: boolean, name: string | null, worktree_deleted: boolean, }; +is_coding_agent_running: boolean, is_errored: boolean, id: string, task_id: string | null, container_ref: string | null, branch: string, setup_completed_at: string | null, created_at: string, updated_at: string, archived: boolean, archived_at: string | null, pinned: boolean, name: string | null, worktree_deleted: boolean, }; + +export type ArchiveBucket = "today" | "one_to_three_days" | "three_to_seven_days" | "seven_to_fifteen_days" | "fifteen_to_thirty_days" | "older_than_thirty_days"; export type Session = { id: string, workspace_id: string, name: string | null, executor: string | null, agent_working_dir: string | null, created_at: string, updated_at: string, }; @@ -418,6 +420,16 @@ export type ChangeTargetBranchRequest = { repo_id: string, new_target_branch: st export type ChangeTargetBranchResponse = { repo_id: string, new_target_branch: string, status: [number, number], }; +export type BulkDeleteTarget = { workspace_id: string, archived_at: string | null, }; + +export type BulkDeleteArchivedWorkspacesRequest = { targets: Array, delete_branches: boolean, }; + +export type BulkDeleteItemOutcome = { "status": "deleted" } | { "status": "skipped", reason: string, } | { "status": "failed", reason: string, }; + +export type BulkDeleteItemResult = { workspace_id: string, workspace_name: string | null, outcome: BulkDeleteItemOutcome, }; + +export type BulkDeleteArchivedWorkspacesResponse = { results: Array, }; + export type AddWorkspaceRepoRequest = { repo_id: string, target_branch: string, }; export type AddWorkspaceRepoResponse = { workspace: Workspace, repo: RepoWithTargetBranch, }; @@ -556,6 +568,10 @@ export type UpdateSession = { name: string | null, }; export type WorkspaceSummaryRequest = { archived: boolean, }; export type WorkspaceSummary = { workspace_id: string, +/** + * Number of repositories/worktrees owned by this workspace. + */ +repo_count: number, /** * Session ID of the latest execution process */