From 77d49c3011163a70e996f77e09e2c960b6910bf8 Mon Sep 17 00:00:00 2001 From: chilli Date: Thu, 3 Sep 2026 19:46:29 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix(conversations):=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=E7=A3=81=E7=9B=98=E4=B8=8A=E5=B7=B2=E6=B6=88=E5=A4=B1=E7=9A=84?= =?UTF-8?q?=20Codex=20=E4=BC=9A=E8=AF=9D=E5=B9=BD=E7=81=B5=E8=A1=8C=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(conversations): prune sidebar ghosts when Codex rollouts are gone Sidebar reads SQLite, not ~/.codex/sessions, so deleted rollout files left openable ghost rows. Soft-delete missing imported sessions on list/scan, cascade orphaned children, and classify Codex resolve-rollout failures. Co-authored-by: Cursor * fix(conversations): skip prune when Codex disk presence is unknown A failed WalkDir/join previously looked like an empty rollout set and would soft-delete every aged Codex row. Distinguish successful-empty from unavailable, finalize prunes with chat-folder cleanup, and cover both with regressions. Co-authored-by: Cursor --------- Co-authored-by: yyy Co-authored-by: Cursor --- src-tauri/src/acp/connection.rs | 21 +- src-tauri/src/commands/conversations.rs | 209 ++++++++++++++++++- src-tauri/src/db/service/import_service.rs | 229 +++++++++++++++++++++ src-tauri/src/parsers/codex.rs | 124 ++++++++++- 4 files changed, 569 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 146fcfd2d4..2ead2b568c 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -7925,8 +7925,14 @@ fn classify_session_load_failure( // "The Claude Agent process exited unexpectedly…" // - "session has ended" → SESSION_ENDED_MESSAGE // - "Session not found" → a plain Error rethrown as an Internal error - const UNRECOVERABLE: &[&str] = - &["process exited", "session has ended", "Session not found"]; + // Codex when the rollout file was deleted out from under us: + // - "failed to resolve rollout path '…': file does not exist" + const UNRECOVERABLE: &[&str] = &[ + "process exited", + "session has ended", + "Session not found", + "failed to resolve rollout", + ]; if UNRECOVERABLE.iter().any(|s| message.contains(s)) { return Some("session_unavailable"); } @@ -14356,6 +14362,17 @@ mod tests { ), Some("session_unavailable"), ); + // Codex deleted the rollout out from under a still-listed conversation. + assert_eq!( + classify_session_load_failure( + sacp::schema::ErrorCode::InternalError, + "Failed to load session, starting new: Internal error: { \"details\": \ + \"failed to resolve rollout path '/Users/feng/.codex/sessions/2026/08/20/\ + rollout-2026-08-20T12-48-42-01a01d7f-fe42-72a1-a6ac-fb9eae4de407.json': \ + file does not exist\" }", + ), + Some("session_unavailable"), + ); } #[test] diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 803f942909..a28447d3f9 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -40,20 +40,25 @@ pub(crate) async fn list_all_conversations_core( chat_channel_manager: &crate::chat_channel::manager::ChatChannelManager, options: ListAllConversationsOptions, ) -> Result, AppCommandError> { - let codex_titles = match tokio::task::spawn_blocking(|| { - CodexParser::new().load_thread_name_index() + let scan = match tokio::task::spawn_blocking(|| { + let parser = CodexParser::new(); + let titles = parser.load_thread_name_index(); + let present = parser.present_session_ids(); + (titles, present) }) .await { - Ok(titles) => titles, - Err(error) => { - tracing::warn!( - error = %error, - "conversation list: failed to load Codex session titles; continuing without refresh" - ); - HashMap::new() - } + Ok((titles, present)) => Ok((titles, present)), + Err(error) => Err(error.to_string()), }; + let (codex_titles, present_codex) = resolve_codex_list_scan(scan); + // Drop sidebar ghosts whose Codex rollout is gone. Runs before `list_all` + // so this response already excludes them; Deleted events keep other + // windows/clients in sync without a second refetch. Skip when the disk + // walk failed — an empty set means "zero rollouts", not "unknown". + if let Some(present) = present_codex.as_ref() { + prune_missing_codex_sessions(conn, emitter, present).await; + } list_all_conversations_core_with_codex_titles( conn, emitter, @@ -64,6 +69,93 @@ pub(crate) async fn list_all_conversations_core( .await } +/// Map the blocking Codex title+presence scan into list_all inputs. +/// +/// `Ok((_, Err(_)))` and join failures both yield `present = None` so prune is +/// skipped; only a successful walk (including a successful empty set) enables +/// prune. +fn resolve_codex_list_scan( + scan: Result<(HashMap, Result, std::io::Error>), String>, +) -> (HashMap, Option>) { + match scan { + Ok((titles, Ok(present))) => (titles, Some(present)), + Ok((titles, Err(error))) => { + tracing::warn!( + error = %error, + "conversation list: Codex rollout walk failed; continuing without prune" + ); + (titles, None) + } + Err(error) => { + tracing::warn!( + error = %error, + "conversation list: failed to load Codex session index; continuing without refresh/prune" + ); + (HashMap::new(), None) + } + } +} + +/// Soft-delete live Codex conversations whose rollout is no longer under +/// `~/.codex/sessions` or `archived_sessions`, then broadcast deletion so every +/// sidebar drops the row. +async fn prune_missing_codex_sessions( + conn: &sea_orm::DatabaseConnection, + emitter: &EventEmitter, + present_codex: &HashSet, +) { + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + + let Ok(rows) = conversation::Entity::find() + .filter(conversation::Column::ExternalId.is_not_null()) + .filter(conversation::Column::AgentType.eq("codex")) + .filter(conversation::Column::DeletedAt.is_null()) + .all(conn) + .await + else { + return; + }; + let present: HashSet<(String, String)> = present_codex + .iter() + .map(|id| ("codex".to_string(), id.clone())) + .collect(); + let pruned = + import_service::prune_missing_imported_sessions(conn, &rows, &present).await; + finalize_pruned_conversations(emitter, conn, &pruned).await; +} + +/// Broadcast deletion and run the same post-delete cleanups as a user delete +/// (tabs, canvas, chat-folder). The row is already soft-deleted by +/// [`import_service::prune_missing_imported_sessions`]. +async fn finalize_pruned_conversations( + emitter: &EventEmitter, + conn: &sea_orm::DatabaseConnection, + pruned: &[i32], +) { + use sea_orm::EntityTrait; + + for &conversation_id in pruned { + // Capture folder_id from the soft-deleted row (get_by_id filters those out). + let folder_id = conversation::Entity::find_by_id(conversation_id) + .one(conn) + .await + .ok() + .flatten() + .map(|row| row.folder_id); + emit_conversation_deleted(emitter, conversation_id); + cleanup_tabs_for_deleted_conversation(emitter, conn, conversation_id).await; + crate::commands::canvas::cleanup_canvas_for_deleted_conversation( + emitter, + conn, + conversation_id, + ) + .await; + if let Some(folder_id) = folder_id { + cleanup_chat_folder_for_deleted_conversation(conn, folder_id).await; + } + } +} + async fn list_all_conversations_core_with_codex_titles( conn: &sea_orm::DatabaseConnection, emitter: &EventEmitter, @@ -719,6 +811,17 @@ async fn scan_importable_sessions_from_summaries( .await, ); + // Soft-delete rows whose agent-side files disappeared since import. Without + // this the sidebar keeps showing ghosts that fail to open. Cleanup runs the + // same funnel as a user delete so tabs/canvas/chat-folders converge. + // Skip entirely when the Codex disk walk failed — never treat "unknown" + // as "empty". + if let Some(present) = import_service::present_keys_with_codex_disk(&summaries) { + let pruned = + import_service::prune_missing_imported_sessions(conn, &conv_rows, &present).await; + finalize_pruned_conversations(emitter, conn, &pruned).await; + } + let folder_rows = load_folder_rows(conn).await?; Ok(build_scan_result(summaries, &imported_index, &folder_rows)) } @@ -3984,6 +4087,92 @@ mod tests { ); } + #[tokio::test] + async fn scan_importable_sessions_prunes_missing_disk_sessions() { + use sea_orm::{ActiveModelTrait, EntityTrait, Set}; + + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/codeg-scan-prune-missing").await; + let row = conversation_service::create( + &db.conn, + folder_id, + AgentType::ClaudeCode, + Some("ghost".into()), + None, + ) + .await + .expect("create"); + conversation_service::bind_external_id(&db.conn, row.id, "vanished-session", &[]) + .await + .expect("bind"); + // Age the row past the prune grace window. + { + let model = conversation::Entity::find_by_id(row.id) + .one(&db.conn) + .await + .expect("query") + .expect("row"); + let mut active: conversation::ActiveModel = model.into(); + active.created_at = Set(at(-7200)); + active.updated_at = Set(at(-7200)); + active.update(&db.conn).await.expect("age row"); + } + let (broadcaster, emitter) = sync_test_emitter(); + let mut events = broadcaster.subscribe(); + let chat_channel_manager = crate::chat_channel::manager::ChatChannelManager::new(); + + // Empty summaries = nothing on disk for this agent. + let result = scan_importable_sessions_from_summaries( + &db.conn, + &emitter, + &chat_channel_manager, + vec![], + ) + .await + .expect("scan"); + assert_eq!(result.total_sessions, 0); + + let stored = conversation::Entity::find_by_id(row.id) + .one(&db.conn) + .await + .expect("query") + .expect("row"); + assert!( + stored.deleted_at.is_some(), + "scan must soft-delete conversations whose files vanished" + ); + let event = events + .try_recv() + .expect("prune must broadcast conversation://changed Deleted"); + assert_eq!(event.channel, CONVERSATION_CHANGED_EVENT); + assert_eq!(event.payload["kind"], "deleted"); + assert_eq!(event.payload["id"], row.id); + } + + #[test] + fn resolve_codex_list_scan_skips_prune_when_walk_or_join_fails() { + let titles = HashMap::from([("s".into(), "t".into())]); + let (got_titles, present) = resolve_codex_list_scan(Ok(( + titles.clone(), + Err(std::io::Error::other("permission denied")), + ))); + assert_eq!(got_titles, titles); + assert!( + present.is_none(), + "a failed walk must disable prune, not pass an empty set" + ); + + let (_, present) = resolve_codex_list_scan(Err("join failed".into())); + assert!(present.is_none()); + + let (_, present) = resolve_codex_list_scan(Ok((HashMap::new(), Ok(HashSet::new())))); + assert_eq!( + present, + Some(HashSet::new()), + "a successful empty walk still enables prune" + ); + } + /// The channel half of a title notification must not be on the caller's /// critical path: `edit_thread_title` reaches Telegram with a 60s per-call /// timeout, and `list_all_conversations` is the sidebar's primary read. diff --git a/src-tauri/src/db/service/import_service.rs b/src-tauri/src/db/service/import_service.rs index a347ae04fb..5a12d31226 100644 --- a/src-tauri/src/db/service/import_service.rs +++ b/src-tauri/src/db/service/import_service.rs @@ -339,6 +339,162 @@ pub(crate) async fn sync_imported_sessions( refreshed } +/// Soft-delete imported conversations whose agent-side session files are gone. +/// +/// The sidebar reads SQLite, not the agent session directories: once a row is +/// imported (or an ACP session is bound), deleting the rollout on disk leaves a +/// ghost that still opens — and then fails with "failed to resolve rollout +/// path". This reconcile walks the same row set the import scan already loaded +/// and removes every live, non-delegation row whose `(agent_type, external_id)` +/// is absent from `present`. +/// +/// `present` is the set of session ids still on disk for each agent (from the +/// filesystem scan, plus Codex archived rollouts). Rows younger than +/// [`PRUNE_GRACE`] are skipped so a just-created ACP session whose rollout has +/// not landed yet is not deleted mid-handshake. +/// +/// Returns the soft-deleted conversation ids so the caller can broadcast +/// `conversation://changed` Deleted events and run tab/canvas cleanup. +pub(crate) async fn prune_missing_imported_sessions( + conn: &DatabaseConnection, + rows: &[conversation::Model], + present: &std::collections::HashSet<(String, String)>, +) -> Vec { + use chrono::{Duration, Utc}; + + /// Brand-new ACP sessions can bind an `external_id` a moment before the + /// agent writes its rollout. Skip anything that young. + const PRUNE_GRACE: Duration = Duration::seconds(120); + + let cutoff = Utc::now() - PRUNE_GRACE; + let mut pruned = Vec::new(); + for row in rows { + if row.deleted_at.is_some() { + continue; + } + // Delegation children are handled when their parent is pruned (below), + // or when their parent is already gone (orphaned-child pass at the end). + if row.parent_id.is_some() { + continue; + } + let Some(external_id) = row.external_id.as_deref() else { + continue; + }; + if present.contains(&(row.agent_type.clone(), external_id.to_string())) { + continue; + } + if row.created_at > cutoff { + continue; + } + match conversation_service::soft_delete(conn, row.id).await { + Ok(()) => { + tracing::info!( + conversation_id = row.id, + agent_type = %row.agent_type, + external_id, + "pruned conversation whose agent session file is gone" + ); + pruned.push(row.id); + if let Ok(children) = conversation_service::list_children(conn, row.id).await { + for child in children { + match conversation_service::soft_delete(conn, child.id).await { + Ok(()) => pruned.push(child.id), + Err(e) => tracing::warn!( + conversation_id = child.id, + parent_id = row.id, + error = %e, + "failed to prune child of missing imported session" + ), + } + } + } + } + Err(e) => tracing::warn!( + conversation_id = row.id, + error = %e, + "failed to prune missing imported session" + ), + } + } + + // A previous prune (before cascade) — or a cross-agent child of a pruned + // parent — may leave live children hanging off an already-deleted parent. + // Soft-delete those orphans so they cannot resurface through + // include_children / child_count paths. Query the DB directly rather than + // trusting `rows`: the list_all Codex-only pass never sees a Claude child + // of a deleted Codex parent. + use sea_orm::EntityTrait; + let Ok(live_children) = conversation::Entity::find() + .filter(conversation::Column::ParentId.is_not_null()) + .filter(conversation::Column::DeletedAt.is_null()) + .all(conn) + .await + else { + return pruned; + }; + for row in live_children { + let Some(parent_id) = row.parent_id else { + continue; + }; + let parent_gone = match conversation::Entity::find_by_id(parent_id).one(conn).await { + Ok(Some(parent)) => parent.deleted_at.is_some(), + Ok(None) => true, + Err(_) => continue, + }; + if !parent_gone { + continue; + } + match conversation_service::soft_delete(conn, row.id).await { + Ok(()) => { + tracing::info!( + conversation_id = row.id, + parent_id, + "pruned orphaned child of a deleted conversation" + ); + pruned.push(row.id); + } + Err(e) => tracing::warn!( + conversation_id = row.id, + parent_id, + error = %e, + "failed to prune orphaned child" + ), + } + } + pruned +} + +/// Build the on-disk presence set from a scan's summaries, then add every Codex +/// session that still has a rollout under `sessions/` or `archived_sessions/`. +/// Archived sessions are absent from the normal Codex parser listing but must +/// not be pruned — they reopen via `codex unarchive`. +/// +/// Returns `None` when the Codex disk walk fails: callers must skip prune +/// entirely rather than treat "unknown" as "empty". +pub(crate) fn present_keys_with_codex_disk( + summaries: &[(AgentType, ConversationSummary)], +) -> Option> { + let mut present: std::collections::HashSet<(String, String)> = summaries + .iter() + .map(|(at, s)| (agent_type_db_str(at), s.id.clone())) + .collect(); + match crate::parsers::codex::CodexParser::new().present_session_ids() { + Ok(ids) => { + for id in ids { + present.insert((agent_type_db_str(&AgentType::Codex), id)); + } + Some(present) + } + Err(error) => { + tracing::warn!( + error = %error, + "codex disk presence walk failed; skipping prune of missing imported sessions" + ); + None + } + } +} + /// Insert a brand-new conversation, or — when it already exists — refresh it in /// place from the freshly parsed session file (see [`refresh_existing`]). async fn import_one( @@ -938,6 +1094,79 @@ mod tests { assert!(row.deleted_at.is_some()); } + #[tokio::test] + async fn prune_missing_imported_sessions_soft_deletes_absent_rows() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-prune-missing").await; + let at = AgentType::Codex; + + let old = Utc::now() - Duration::hours(2); + import_one( + &db.conn, + folder, + &at, + &timed_summary("gone-session", Some("ghost"), old, 3), + ) + .await + .expect("import ghost"); + import_one( + &db.conn, + folder, + &at, + &timed_summary("live-session", Some("kept"), old, 2), + ) + .await + .expect("import live"); + let gone_id = find_id(&db.conn, "gone-session").await; + let live_id = find_id(&db.conn, "live-session").await; + + let present = std::collections::HashSet::from([( + agent_type_db_str(&at), + "live-session".to_string(), + )]); + let rows = external_rows(&db.conn).await; + assert_eq!( + prune_missing_imported_sessions(&db.conn, &rows, &present).await, + vec![gone_id] + ); + + let gone = find_row(&db.conn, "gone-session").await; + assert!(gone.deleted_at.is_some(), "absent session must be pruned"); + let live = find_row(&db.conn, "live-session").await; + assert!(live.deleted_at.is_none(), "present session must stay"); + assert_eq!(live.id, live_id); + } + + #[tokio::test] + async fn prune_missing_imported_sessions_skips_fresh_rows() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-prune-grace").await; + let at = AgentType::Codex; + + // Just created — rollout may not be on disk yet. Use a summary whose + // started_at is *now* (timed_summary backs it by an hour). + let now = Utc::now(); + let mut fresh = summary("brand-new", Some("inflight")); + fresh.started_at = now; + fresh.ended_at = Some(now); + fresh.message_count = 0; + import_one(&db.conn, folder, &at, &fresh) + .await + .expect("import"); + let id = find_id(&db.conn, "brand-new").await; + let rows = external_rows(&db.conn).await; + let present = std::collections::HashSet::new(); + assert!( + prune_missing_imported_sessions(&db.conn, &rows, &present) + .await + .is_empty(), + "a just-created session must survive the grace window" + ); + let row = find_row(&db.conn, "brand-new").await; + assert!(row.deleted_at.is_none()); + assert_eq!(row.id, id); + } + #[tokio::test] async fn reimport_skips_a_delegation_child() { let db = fresh_in_memory_db().await; diff --git a/src-tauri/src/parsers/codex.rs b/src-tauri/src/parsers/codex.rs index 16915faddb..7171a9e094 100644 --- a/src-tauri/src/parsers/codex.rs +++ b/src-tauri/src/parsers/codex.rs @@ -173,12 +173,41 @@ impl CodexParser { if session_id.is_empty() || session_id.contains(['/', '\\']) || session_id.contains("..") { return None; } - WalkDir::new(&self.base_dir) + self.find_rollout_in_dir(&self.base_dir, session_id) + } + + /// Every session id that still has a rollout under `sessions/` or + /// `archived_sessions/`. Used to prune DB rows whose agent-side files are + /// gone without opening each file. + /// + /// Returns `Err` when the walk hits an I/O / permission error: callers must + /// treat that as "disk presence unknown" and **skip** prune, never as an + /// empty set (an empty set means "successfully observed zero rollouts"). + pub(crate) fn present_session_ids(&self) -> Result, std::io::Error> { + let mut ids = HashSet::new(); + self.collect_session_ids_from_dir(&self.base_dir, &mut ids)?; + if let Some(home) = self.base_dir.parent() { + self.collect_session_ids_from_dir(&home.join("archived_sessions"), &mut ids)?; + } + Ok(ids) + } + + fn find_rollout_in_dir( + &self, + dir: &Path, + session_id: &str, + ) -> Option { + if !dir.exists() { + return None; + } + WalkDir::new(dir) .into_iter() .filter_map(Result::ok) .map(|entry| entry.path().to_path_buf()) .find(|path| { - if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { + let ext = path.extension().and_then(|e| e.to_str()); + // Codex historically wrote `.json`; current rollouts are `.jsonl`. + if ext != Some("jsonl") && ext != Some("json") { return false; } let name = path.file_name().unwrap_or_default().to_string_lossy(); @@ -186,6 +215,36 @@ impl CodexParser { }) } + fn collect_session_ids_from_dir( + &self, + dir: &Path, + ids: &mut HashSet, + ) -> Result<(), std::io::Error> { + if !dir.exists() { + return Ok(()); + } + for entry in WalkDir::new(dir).into_iter() { + let entry = entry.map_err(|e| { + std::io::Error::new( + e.io_error() + .map(|io| io.kind()) + .unwrap_or(std::io::ErrorKind::Other), + e.to_string(), + ) + })?; + let path = entry.path(); + let ext = path.extension().and_then(|e| e.to_str()); + if ext != Some("jsonl") && ext != Some("json") { + continue; + } + let name = path.file_name().unwrap_or_default().to_string_lossy(); + if let Some(id) = session_id_from_rollout_filename(&name) { + ids.insert(id); + } + } + Ok(()) + } + /// Load Codex's append-only session title index. The transcript remains the /// fallback source, so a missing/unreadable index or a malformed line is /// deliberately ignored. Later non-empty records for the same session win. @@ -741,6 +800,35 @@ impl AgentParser for CodexParser { } } +/// Extract the session id embedded in a Codex rollout filename +/// (`rollout--.jsonl`). The id is the trailing UUID. +fn session_id_from_rollout_filename(name: &str) -> Option { + let stem = name + .strip_prefix("rollout-")? + .strip_suffix(".jsonl") + .or_else(|| name.strip_prefix("rollout-")?.strip_suffix(".json"))?; + // UUID is 8-4-4-4-12 (36 chars with hyphens) at the end of the stem. + if stem.len() < 36 { + return None; + } + let candidate = &stem[stem.len() - 36..]; + let mut parts = candidate.split('-'); + let looks_like_uuid = matches!( + ( + parts.next().map(|p| p.len()), + parts.next().map(|p| p.len()), + parts.next().map(|p| p.len()), + parts.next().map(|p| p.len()), + parts.next().map(|p| p.len()), + parts.next(), + ), + (Some(8), Some(4), Some(4), Some(4), Some(12), None) + ) && candidate + .bytes() + .all(|b| b.is_ascii_hexdigit() || b == b'-'); + looks_like_uuid.then(|| candidate.to_string()) +} + fn parse_codex_json_arg(payload: &serde_json::Value) -> Option { let args = payload.get("arguments").or_else(|| payload.get("input"))?; if let Some(s) = args.as_str() { @@ -5578,6 +5666,38 @@ fn group_into_turns(messages: Vec) -> Vec { #[cfg(test)] mod tests { + /// An empty, readable sessions dir is a successful empty presence set — + /// callers may prune. An unreadable dir must return Err so callers skip + /// prune instead of treating the failure as "zero rollouts". + #[test] + fn present_session_ids_distinguishes_empty_from_unreadable() { + let temp_dir = tempfile::tempdir().expect("temp"); + let sessions_dir = temp_dir.path().join("sessions"); + fs::create_dir_all(&sessions_dir).expect("sessions"); + let parser = CodexParser::with_base_dir(sessions_dir.clone()); + assert!( + parser + .present_session_ids() + .expect("readable empty dir") + .is_empty() + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&sessions_dir, fs::Permissions::from_mode(0o000)) + .expect("chmod"); + let result = CodexParser::with_base_dir(sessions_dir.clone()).present_session_ids(); + // Restore so tempfile cleanup can remove the dir. + fs::set_permissions(&sessions_dir, fs::Permissions::from_mode(0o755)) + .expect("chmod restore"); + assert!( + result.is_err(), + "unreadable sessions dir must not look like an empty successful walk" + ); + } + } + /// codex-acp 1.8.0 forks BY REFERENCE: the child's rollout carries no /// history, only `forked_from_id` + `forked_from_ordinal_exclusive`. Read /// alone it parses to zero turns, which is what put "this session has no From f4270bfc843a720227bde77fae0378c887a181ed Mon Sep 17 00:00:00 2001 From: chilli Date: Wed, 9 Sep 2026 07:39:08 +0800 Subject: [PATCH 2/7] chore(tauri): ignore managed target compatibility symlink (#2) Allow src-tauri/target to be ignored as either a directory or a symlink, matching the DevCache cargo bound compatibility layout. --- src-tauri/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index b21bd681d9..f0fecc358b 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -1,5 +1,6 @@ # Generated by Cargo # will have compiled files and executables +/target /target/ # Generated by Tauri From 17b024f3c66a323706f783f55888ddf4c36ee9c1 Mon Sep 17 00:00:00 2001 From: chilli Date: Wed, 7 Oct 2026 17:45:42 +0800 Subject: [PATCH 3/7] fix(acp): mark Pi sessions stale when native model config changes Pi only reads settings/auth/models at process start, so saving a new provider or model must fingerprint those files and refresh running sessions instead of leaving them on the old registry. Co-authored-by: Cursor --- src-tauri/src/commands/acp.rs | 85 ++++++++++++++++++++++++++++++- src-tauri/src/web/handlers/acp.rs | 4 +- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index bb6fc62bfa..e6c77f5701 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -7165,6 +7165,28 @@ pub(crate) async fn acp_update_pi_config_core( Ok(()) } +/// Apply a Pi native-config update and mark running Pi sessions that still use +/// the old files as stale. Pi reads `settings.json`, `auth.json`, and +/// `models.json` only when its process starts, so a live ACP process cannot +/// discover a newly-added model until it is reconnected. +pub(crate) async fn acp_update_pi_config_and_refresh( + update: PiConfigUpdate, + db: &AppDatabase, + manager: &ConnectionManager, + data_dir: &Path, + emitter: &EventEmitter, +) -> Result { + acp_update_pi_config_core(update, db, emitter).await?; + Ok(refresh_config_staleness( + manager, + db, + data_dir, + &[AgentType::Pi], + ConfigStaleKind::AgentConfig, + ) + .await) +} + /// Projection of pi's current native config for the settings panel: the three /// `settings.json` model keys plus the provider names present in `auth.json` /// (sorted). Missing files surface as all-`None` / empty. @@ -11018,6 +11040,28 @@ pub(crate) fn fingerprint_config( hasher.update(json.as_bytes()); } } + // Pi keeps its provider/model registry and credentials in three native + // files outside codeg's generic local-config path. They are all read at + // pi process startup, so changes must make an existing session stale. Hash + // the raw bytes (including an explicit missing-file marker) rather than + // parsing them; this tracks additions/removals and custom fields while + // keeping secrets out of the fingerprint itself. + if agent_type == AgentType::Pi { + let pi_dir = pi_agent_dir_for_env(runtime_env); + for file in ["settings.json", "auth.json", "models.json"] { + hasher.update(b"\x01pi_native_file\x01"); + hasher.update(file.as_bytes()); + hasher.update([0u8]); + match fs::read(pi_dir.join(file)) { + Ok(raw) => { + hasher.update([1u8]); + hasher.update(raw); + } + Err(_) => hasher.update([0u8]), + } + hasher.update([0u8]); + } + } format!("{:x}", hasher.finalize()) } @@ -12634,7 +12678,7 @@ pub async fn acp_fetch_kimi_models( /// Apply a structured Pi config update, writing pi's native `settings.json` /// (provider/model/thinking level) and `auth.json` (when an API key is given). -/// Desktop command; the web handler calls `acp_update_pi_config_core` directly. +/// Desktop and web saves share the native write and staleness refresh. #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] #[allow(clippy::too_many_arguments)] @@ -12646,11 +12690,17 @@ pub async fn acp_update_pi_config( custom_base_url: Option, custom_api: Option, model_reasoning: Option, + manager: State<'_, ConnectionManager>, db: State<'_, AppDatabase>, app: tauri::AppHandle, ) -> Result<(), AcpError> { + let app_data_dir = app + .path() + .app_data_dir() + .map(|p| crate::paths::resolve_effective_data_dir(&p)) + .unwrap_or_else(|_| std::path::PathBuf::from(".")); let emitter = EventEmitter::Tauri(app); - acp_update_pi_config_core( + acp_update_pi_config_and_refresh( PiConfigUpdate { provider, model, @@ -12661,9 +12711,12 @@ pub async fn acp_update_pi_config( model_reasoning, }, &db, + &manager, + &app_data_dir, &emitter, ) .await + .map(|_| ()) } /// Read pi's current native config from the same per-agent directory that @@ -17358,6 +17411,34 @@ wire_api = "chat" }); } + #[test] + fn pi_fingerprint_tracks_native_model_files_and_custom_agent_dir() { + // Pi's provider/model registry lives outside the generic local-config + // path. A custom PI_CODING_AGENT_DIR must be hashed too, because that is + // the directory the spawned pi process actually reads. + let dir = tempfile::tempdir().expect("tempdir"); + temp_env::with_var("PI_CODING_AGENT_DIR", Some(dir.path()), || { + let env: BTreeMap = BTreeMap::new(); + let empty_fp = fingerprint_config(AgentType::Pi, &env); + + std::fs::write( + dir.path().join("models.json"), + r#"{"providers":{"tokenkey":{"models":[{"id":"gpt-6"}]}}}"#, + ) + .expect("write models"); + let models_fp = fingerprint_config(AgentType::Pi, &env); + assert_ne!(empty_fp, models_fp); + + std::fs::write( + dir.path().join("settings.json"), + r#"{"defaultProvider":"tokenkey","defaultModel":"gpt-6"}"#, + ) + .expect("write settings"); + let settings_fp = fingerprint_config(AgentType::Pi, &env); + assert_ne!(models_fp, settings_fp); + }); + } + /// Parse a provider table out of a TOML fragment so the auth-default tests /// read like the config.toml a user would actually write. fn provider_table_of(raw: &str) -> toml::map::Map { diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index 758e44031b..0476c850db 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -922,7 +922,7 @@ pub async fn acp_update_pi_config( Json(params): Json, ) -> Result, AppCommandError> { let emitter = state.emitter.clone(); - acp_commands::acp_update_pi_config_core( + acp_commands::acp_update_pi_config_and_refresh( acp_commands::PiConfigUpdate { provider: params.provider, model: params.model, @@ -933,6 +933,8 @@ pub async fn acp_update_pi_config( model_reasoning: params.model_reasoning, }, &state.db, + &state.connection_manager, + &state.data_dir, &emitter, ) .await From 43acb5805f9d3c6c97176e3c0ecc6e3542ec0b1b Mon Sep 17 00:00:00 2001 From: chilli Date: Wed, 7 Oct 2026 18:09:09 +0800 Subject: [PATCH 4/7] refactor(acp): move Pi fingerprint hashing out of commands/acp hotspot Shrink fork conflict surface on the mega upstream file by keeping only a one-line hook there, drop trailing-newline noise vs upstream, and cover the hash in acp::pi_native_fingerprint. Co-authored-by: Cursor --- src-tauri/Cargo.toml | 1 + src-tauri/src/acp/mod.rs | 1 + src-tauri/src/acp/pi_native_fingerprint.rs | 78 ++++++++++++++++++++++ src-tauri/src/commands/acp.rs | 43 +++--------- src-tauri/tests/browser_bridge_off.rs | 1 + 5 files changed, 90 insertions(+), 34 deletions(-) create mode 100644 src-tauri/src/acp/pi_native_fingerprint.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index db24c5c5c9..1df82236d7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -308,3 +308,4 @@ temp-env = { version = "0.3", features = ["async_closure"] } tokio = { version = "1", features = ["test-util", "macros", "rt", "sync", "time"] } insta = { version = "1", features = ["json", "redactions"] } axum-test = { version = "17", features = ["ws"] } + diff --git a/src-tauri/src/acp/mod.rs b/src-tauri/src/acp/mod.rs index 524321bf70..e375ef19b1 100644 --- a/src-tauri/src/acp/mod.rs +++ b/src-tauri/src/acp/mod.rs @@ -32,6 +32,7 @@ pub mod manager; pub mod opencode_catalog; pub mod opencode_launch; pub mod opencode_plugins; +pub mod pi_native_fingerprint; pub mod plan_approval; pub mod preflight; pub mod prompt_hydration; diff --git a/src-tauri/src/acp/pi_native_fingerprint.rs b/src-tauri/src/acp/pi_native_fingerprint.rs new file mode 100644 index 0000000000..8be18d614d --- /dev/null +++ b/src-tauri/src/acp/pi_native_fingerprint.rs @@ -0,0 +1,78 @@ +//! Pi-native config files that belong in the session config fingerprint. +//! +//! Kept out of `commands/acp.rs` so fork-specific Pi staleness logic does not +//! keep growing that upstream hotspot and inflate merge-tree conflict surface. + +use std::fs; +use std::path::Path; + +use sha2::Digest; + +/// Filenames Pi reads at process start for provider/model registry and +/// credentials. Changes here must mark running Pi sessions restart-required. +pub(crate) const PI_NATIVE_FINGERPRINT_FILES: &[&str] = + &["settings.json", "auth.json", "models.json"]; + +/// Hash Pi's native config directory into `hasher`. +/// +/// Raw bytes (plus an explicit missing-file marker) rather than parsed JSON so +/// additions/removals and custom fields are tracked while secrets never appear +/// in the fingerprint string itself — only in the digest input. +pub(crate) fn hash_pi_native_dir_into(hasher: &mut impl Digest, pi_dir: &Path) { + for file in PI_NATIVE_FINGERPRINT_FILES { + hasher.update(b"\x01pi_native_file\x01"); + hasher.update(file.as_bytes()); + hasher.update([0u8]); + match fs::read(pi_dir.join(file)) { + Ok(raw) => { + hasher.update([1u8]); + hasher.update(raw); + } + Err(_) => hasher.update([0u8]), + } + hasher.update([0u8]); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sha2::Sha256; + + fn digest_hex(pi_dir: &Path) -> String { + let mut hasher = Sha256::new(); + hash_pi_native_dir_into(&mut hasher, pi_dir); + format!("{:x}", hasher.finalize()) + } + + #[test] + fn hash_changes_when_models_or_settings_appear() { + let dir = tempfile::tempdir().expect("tempdir"); + let empty = digest_hex(dir.path()); + + std::fs::write( + dir.path().join("models.json"), + r#"{"providers":{"tokenkey":{"models":[{"id":"gpt-6"}]}}}"#, + ) + .expect("write models"); + let with_models = digest_hex(dir.path()); + assert_ne!(empty, with_models); + + std::fs::write( + dir.path().join("settings.json"), + r#"{"defaultProvider":"tokenkey","defaultModel":"gpt-6"}"#, + ) + .expect("write settings"); + let with_settings = digest_hex(dir.path()); + assert_ne!(with_models, with_settings); + } + + #[test] + fn missing_and_present_files_differ() { + let dir = tempfile::tempdir().expect("tempdir"); + let missing = digest_hex(dir.path()); + std::fs::write(dir.path().join("auth.json"), r#"{"tokenkey":{"type":"api_key"}}"#) + .expect("write auth"); + assert_ne!(missing, digest_hex(dir.path())); + } +} diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index e6c77f5701..5cb56dc626 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -11040,27 +11040,12 @@ pub(crate) fn fingerprint_config( hasher.update(json.as_bytes()); } } - // Pi keeps its provider/model registry and credentials in three native - // files outside codeg's generic local-config path. They are all read at - // pi process startup, so changes must make an existing session stale. Hash - // the raw bytes (including an explicit missing-file marker) rather than - // parsing them; this tracks additions/removals and custom fields while - // keeping secrets out of the fingerprint itself. + // Pi native settings/auth/models — hashed in `pi_native_fingerprint`. if agent_type == AgentType::Pi { - let pi_dir = pi_agent_dir_for_env(runtime_env); - for file in ["settings.json", "auth.json", "models.json"] { - hasher.update(b"\x01pi_native_file\x01"); - hasher.update(file.as_bytes()); - hasher.update([0u8]); - match fs::read(pi_dir.join(file)) { - Ok(raw) => { - hasher.update([1u8]); - hasher.update(raw); - } - Err(_) => hasher.update([0u8]), - } - hasher.update([0u8]); - } + crate::acp::pi_native_fingerprint::hash_pi_native_dir_into( + &mut hasher, + &pi_agent_dir_for_env(runtime_env), + ); } format!("{:x}", hasher.finalize()) } @@ -17413,29 +17398,19 @@ wire_api = "chat" #[test] fn pi_fingerprint_tracks_native_model_files_and_custom_agent_dir() { - // Pi's provider/model registry lives outside the generic local-config - // path. A custom PI_CODING_AGENT_DIR must be hashed too, because that is - // the directory the spawned pi process actually reads. + // Wiring check: fingerprint_config must honor PI_CODING_AGENT_DIR via + // pi_agent_dir_for_env. File-level hash behaviour is covered in + // `acp::pi_native_fingerprint` so this hotspot stays a thin hook. let dir = tempfile::tempdir().expect("tempdir"); temp_env::with_var("PI_CODING_AGENT_DIR", Some(dir.path()), || { let env: BTreeMap = BTreeMap::new(); let empty_fp = fingerprint_config(AgentType::Pi, &env); - std::fs::write( dir.path().join("models.json"), r#"{"providers":{"tokenkey":{"models":[{"id":"gpt-6"}]}}}"#, ) .expect("write models"); - let models_fp = fingerprint_config(AgentType::Pi, &env); - assert_ne!(empty_fp, models_fp); - - std::fs::write( - dir.path().join("settings.json"), - r#"{"defaultProvider":"tokenkey","defaultModel":"gpt-6"}"#, - ) - .expect("write settings"); - let settings_fp = fingerprint_config(AgentType::Pi, &env); - assert_ne!(models_fp, settings_fp); + assert_ne!(empty_fp, fingerprint_config(AgentType::Pi, &env)); }); } diff --git a/src-tauri/tests/browser_bridge_off.rs b/src-tauri/tests/browser_bridge_off.rs index ae68991fb1..c7486e2b3b 100644 --- a/src-tauri/tests/browser_bridge_off.rs +++ b/src-tauri/tests/browser_bridge_off.rs @@ -93,3 +93,4 @@ async fn switching_the_bridge_off_closes_everything_and_refuses_new_opens() { assert_eq!(response.status(), 200); browser_bridge::close("tab-on"); } + From fcd2fc49e87cf9487f3e99bbd07efda8733b61f5 Mon Sep 17 00:00:00 2001 From: chilli Date: Wed, 7 Oct 2026 18:26:52 +0800 Subject: [PATCH 5/7] fix(conversations): alias CodexListScan for clippy type_complexity CI Rust 1.99 -D warnings rejected the nested Result/HashMap/HashSet parameter on resolve_codex_list_scan; a named alias keeps the same semantics without an allow attribute. Co-authored-by: Cursor --- src-tauri/src/commands/conversations.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index c8bf01352a..b01f83ae5c 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -69,13 +69,17 @@ pub(crate) async fn list_all_conversations_core( .await } +/// Blocking Codex list scan: titles map plus the rollout-presence walk. +type CodexListScan = + Result<(HashMap, Result, std::io::Error>), String>; + /// Map the blocking Codex title+presence scan into list_all inputs. /// /// `Ok((_, Err(_)))` and join failures both yield `present = None` so prune is /// skipped; only a successful walk (including a successful empty set) enables /// prune. fn resolve_codex_list_scan( - scan: Result<(HashMap, Result, std::io::Error>), String>, + scan: CodexListScan, ) -> (HashMap, Option>) { match scan { Ok((titles, Ok(present))) => (titles, Some(present)), From fd0278c6d7d7c45b9b90b0bac01f8250d2a62dbd Mon Sep 17 00:00:00 2001 From: chilli Date: Sat, 10 Oct 2026 21:18:05 +0800 Subject: [PATCH 6/7] =?UTF-8?q?chore:=20=E6=8E=A5=E5=85=A5=20upstream-merg?= =?UTF-8?q?e-notify=20=E8=B7=9F=E8=B8=AA=E4=B8=8A=E6=B8=B8=E8=90=BD?= =?UTF-8?q?=E5=90=8E=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 接入 upstream-merge-notify 跟踪上游落后 安装 detection-only workflow,及时开 issue 跟进 xintaofei/codeg 新特征。 Co-authored-by: Cursor * fix(ci): refresh upstream-merge-notify install policy note Address R-003; keep payload synced with dev-rules template. no-web-impact Co-authored-by: Cursor --------- Co-authored-by: Cursor --- .github/workflows/upstream-merge-notify.yml | 114 +++ scripts/lib/upstream-drift.sh | 97 +++ scripts/lib/upstream-url.conf | 2 + scripts/upstream/check-drift.sh | 99 +++ scripts/upstream/notify-merge-needed.py | 787 ++++++++++++++++++++ 5 files changed, 1099 insertions(+) create mode 100644 .github/workflows/upstream-merge-notify.yml create mode 100755 scripts/lib/upstream-drift.sh create mode 100644 scripts/lib/upstream-url.conf create mode 100755 scripts/upstream/check-drift.sh create mode 100755 scripts/upstream/notify-merge-needed.py diff --git a/.github/workflows/upstream-merge-notify.yml b/.github/workflows/upstream-merge-notify.yml new file mode 100644 index 0000000000..d9ead3362d --- /dev/null +++ b/.github/workflows/upstream-merge-notify.yml @@ -0,0 +1,114 @@ +# managed-by: dev-rules/templates/upstream-merge-notify +name: Upstream Merge Needed + +# Detection-only. When origin/main is behind the configured upstream/main, +# open or refresh one `[upstream-merge]` tracking issue for a human to handle. +# Install/refresh policy (all-or-nothing vs custom forks) is owned by +# templates/install-upstream-merge-notify.sh — do not hand-edit this file. +# Labels are attached best-effort and must not block issue creation. +# Prefer UPSTREAM_MERGE_GH_TOKEN when set; otherwise fall back to github.token. +# Unset GITHUB_TOKEN in the issue step so gh does not mix tokens. +# This workflow never merges, never opens a PR, and never runs an agent. + +on: + schedule: + # GitHub cron is UTC. + - cron: "0 20 * * *" + workflow_dispatch: + +permissions: + contents: read + issues: write + pull-requests: read + +concurrency: + group: upstream-merge-notify + cancel-in-progress: false + +jobs: + notify: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + # Keep default checkout credentials so `git fetch origin main` works + # for private forks. This workflow never git-push. + + - name: Fetch origin/main and upstream/main + run: | + set -euo pipefail + git fetch origin main --quiet + if ! git remote get-url upstream >/dev/null 2>&1; then + git remote add upstream https://github.com/xintaofei/codeg.git + fi + git fetch upstream main --quiet + + - name: Measure drift + run: | + set +e + bash scripts/upstream/check-drift.sh --json > /tmp/upstream-merge-drift.json + rc=$? + set -e + if [ "$rc" -eq 2 ]; then + echo "::error::check-drift.sh failed (git/network)" + exit 2 + fi + # rc 0 = in sync, rc 1 = behind — both are valid inputs. + cat /tmp/upstream-merge-drift.json + + - name: Collect commit list and merge-tree conflicts + run: | + set -euo pipefail + git log --format='%h %s' origin/main..upstream/main > /tmp/upstream-merge-commits.txt + set +e + git merge-tree --write-tree --name-only --no-messages origin/main upstream/main \ + > /tmp/upstream-merge-tree.txt + mt_rc=$? + set -e + if [ "$mt_rc" -gt 1 ]; then + echo "::error::git merge-tree failed" + cat /tmp/upstream-merge-tree.txt >&2 || true + exit 2 + fi + + - name: List leftover merge/upstream-* PRs + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if ! gh pr list --state open --base main --json number,title,url,headRefName \ + --jq '[.[] | select(.headRefName | startswith("merge/upstream-"))]' \ + > /tmp/upstream-merge-open-prs.json; then + echo '[]' > /tmp/upstream-merge-open-prs.json + fi + + - name: Open, update, or close tracking issue + env: + GH_TOKEN: ${{ secrets.UPSTREAM_MERGE_GH_TOKEN || github.token }} + GH_REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + if [ -z "${GH_TOKEN:-}" ]; then + echo "::error::Missing GH_TOKEN for issue create/update." + exit 1 + fi + unset GITHUB_TOKEN + python3 scripts/upstream/notify-merge-needed.py apply \ + --drift /tmp/upstream-merge-drift.json \ + --commits /tmp/upstream-merge-commits.txt \ + --conflicts /tmp/upstream-merge-tree.txt \ + --open-prs /tmp/upstream-merge-open-prs.json \ + --run-url "$RUN_URL" + + - name: Job summary + if: always() + run: | + { + echo "## Upstream merge notify" + echo "" + echo "- run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + echo "- drift: \`$(tr -d '\n' < /tmp/upstream-merge-drift.json 2>/dev/null || echo missing)\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/scripts/lib/upstream-drift.sh b/scripts/lib/upstream-drift.sh new file mode 100755 index 0000000000..14c692545c --- /dev/null +++ b/scripts/lib/upstream-drift.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# managed-by: dev-rules/templates/upstream-merge-notify + +_upstream_url_conf() { + local here + here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + printf '%s\n' "$here/upstream-url.conf" +} + +resolve_upstream_url() { + if [ -n "${UPSTREAM_URL:-}" ]; then + printf '%s\n' "$UPSTREAM_URL" + return 0 + fi + local conf + conf="$(_upstream_url_conf)" + if [ -f "$conf" ]; then + # Ignore comment / blank lines; first remaining token is the URL. + awk 'NF && $1 !~ /^#/ { print $1; exit }' "$conf" + return 0 + fi + return 1 +} + +is_upstream_drift_gate_required() { + # Pull-request checkouts are detached, so prefer the PR head branch. Pushes + # expose the target branch through GITHUB_REF_NAME; local runs fall back to + # the checked-out branch. Freshness is enforced only while preparing an + # upstream-sync branch; existing upstream drift must not block main or an + # unrelated feature/bugfix PR. + local branch="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-}}" + if [ -z "$branch" ]; then + branch="$(git branch --show-current 2>/dev/null || true)" + fi + + case "$branch" in + merge/upstream-*) return 0 ;; + *) return 1 ;; + esac +} + +ensure_upstream_remote() { + if git remote get-url upstream >/dev/null 2>&1; then + return 0 + fi + local url + if ! url="$(resolve_upstream_url)" || [ -z "$url" ]; then + echo "ERROR: upstream remote missing and UPSTREAM_URL/upstream-url.conf unset" >&2 + return 2 + fi + if declare -F log >/dev/null 2>&1; then + log "Adding upstream remote: $url" + fi + git remote add upstream "$url" +} + +fetch_upstream_drift_refs() { + ensure_upstream_remote || return $? + if ! git fetch upstream main --quiet 2>/dev/null; then + echo "ERROR: failed to fetch upstream/main" >&2 + return 2 + fi + if ! git fetch origin main --quiet 2>/dev/null; then + echo "ERROR: failed to fetch origin/main" >&2 + return 2 + fi +} + +load_upstream_drift_snapshot() { + local head_ref="${1:-origin/main}" target_ref="${2:-upstream/main}" + local head_sha target_sha + head_sha=$(git rev-parse --verify "$head_ref^{commit}") || return 2 + target_sha=$(git rev-parse --verify "$target_ref^{commit}") || return 2 + local merge_head merge_heads_file + local -a reviewed_heads=("$head_sha") + merge_heads_file=$(git rev-parse --git-path MERGE_HEAD) || return 2 + if [ "$head_ref" = "HEAD" ] && [ -f "$merge_heads_file" ]; then + # Before the merge commit, HEAD is still the fork parent. Validate the + # resolved index and count the actual pending parents; never waive drift + # against a target that is not part of this merge. + git write-tree >/dev/null || return 2 + while IFS= read -r merge_head; do + merge_head=$(git rev-parse --verify "$merge_head^{commit}") || return 2 + reviewed_heads+=("$merge_head") + done < "$merge_heads_file" + fi + FORK_BEHIND=$(git rev-list --count "$target_sha" --not "${reviewed_heads[@]}") || return 2 + FORK_AHEAD=$(git rev-list --count "${reviewed_heads[@]}" --not "$target_sha") || return 2 + UPSTREAM_HEAD=$(git rev-parse --short "$target_sha") + ORIGIN_HEAD=$(git rev-parse --short "$head_sha") + export FORK_BEHIND FORK_AHEAD UPSTREAM_HEAD ORIGIN_HEAD +} + +fetch_and_load_upstream_drift_snapshot() { + fetch_upstream_drift_refs || return $? + load_upstream_drift_snapshot "$@" +} diff --git a/scripts/lib/upstream-url.conf b/scripts/lib/upstream-url.conf new file mode 100644 index 0000000000..b6e4280a30 --- /dev/null +++ b/scripts/lib/upstream-url.conf @@ -0,0 +1,2 @@ +# managed-by: dev-rules/templates/upstream-merge-notify +https://github.com/xintaofei/codeg.git diff --git a/scripts/upstream/check-drift.sh b/scripts/upstream/check-drift.sh new file mode 100755 index 0000000000..91cee0d4a8 --- /dev/null +++ b/scripts/upstream/check-drift.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# managed-by: dev-rules/templates/upstream-merge-notify +# scripts/upstream/check-drift.sh — Report whether upstream/main has commits +# not yet merged into the fork's origin/main. +# +# Usage: +# bash scripts/upstream/check-drift.sh # human-readable +# bash scripts/upstream/check-drift.sh --json # JSON for CI consumption +# bash scripts/upstream/check-drift.sh --quiet # exit code only (no output) +# bash scripts/upstream/check-drift.sh --head HEAD --target +# +# Exit codes: +# 0 — fork is in sync (origin/main contains all of upstream/main) +# 1 — upstream is ahead (one or more upstream commits not yet merged) +# 2 — git/network failure (cannot fetch upstream or origin) +# +# Dependencies: git, with the `upstream` remote pointing at the source repo. +# CI environments without an `upstream` remote will get one auto-added from +# UPSTREAM_URL / the value baked in by install-upstream-merge-notify.sh. + +set -euo pipefail + +MODE="human" +HEAD_REF="origin/main" +TARGET_REF="upstream/main" + +while [ "$#" -gt 0 ]; do + case "$1" in + --json) MODE="json" ;; + --quiet) MODE="quiet" ;; + --head|--target) + if [ "$#" -lt 2 ] || [ -z "$2" ]; then + echo "$1 requires a commit reference" >&2 + exit 2 + fi + if [ "$1" = "--head" ]; then HEAD_REF="$2"; else TARGET_REF="$2"; fi + shift + ;; + -h|--help) + sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac + shift +done + +log() { [ "$MODE" = "quiet" ] && return; [ "$MODE" = "json" ] && return; echo "$@"; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/lib/upstream-drift.sh +source "$SCRIPT_DIR/../lib/upstream-drift.sh" + +if ! fetch_and_load_upstream_drift_snapshot "$HEAD_REF" "$TARGET_REF"; then + exit 2 +fi + +BEHIND="$FORK_BEHIND" +AHEAD="$FORK_AHEAD" + +if [ "$MODE" = "json" ]; then + printf '{"behind":%d,"ahead":%d,"upstream_head":"%s","origin_head":"%s","in_sync":%s}\n' \ + "$BEHIND" "$AHEAD" "$UPSTREAM_HEAD" "$ORIGIN_HEAD" \ + "$([ "$BEHIND" -eq 0 ] && echo true || echo false)" +elif [ "$MODE" = "human" ]; then + log "Upstream: $TARGET_REF@$UPSTREAM_HEAD" + log "Fork: $HEAD_REF@$ORIGIN_HEAD" + log "Fork ahead: $AHEAD commits" + log "Fork behind: $BEHIND commits" +fi + +if [ "$BEHIND" -eq 0 ]; then + log "" + log "Fork is in sync with $TARGET_REF." + exit 0 +fi + +if [ "$MODE" = "human" ]; then + log "" + log "Upstream has $BEHIND new commits not yet merged into the fork:" + log "" + git log --oneline -20 "$HEAD_REF..$TARGET_REF" | sed 's/^/ /' + if [ "$BEHIND" -gt 20 ]; then + log " ... ($((BEHIND - 20)) more)" + fi + log "" + log "Next steps (upstream-merge skill):" + log "" + log " git fetch upstream && git fetch origin" + log " # create merge/upstream-YYYY-MM-DD via git-worktree-submodule" + log " git merge --no-ff upstream/main # MUST be --no-ff, never --squash" + log " # run host preflight / project gates" + log " git push -u origin merge/upstream-\$(date -u +%Y-%m-%d)" + log " gh pr create --base main --title 'chore: merge upstream/main (...)'" + log "" + log " # Reviewer MUST pick GitHub 'Create a merge commit' (not Squash)." +fi + +exit 1 diff --git a/scripts/upstream/notify-merge-needed.py b/scripts/upstream/notify-merge-needed.py new file mode 100755 index 0000000000..e1902d04e3 --- /dev/null +++ b/scripts/upstream/notify-merge-needed.py @@ -0,0 +1,787 @@ +#!/usr/bin/env python3 +# managed-by: dev-rules/templates/upstream-merge-notify +"""Open or refresh a human-facing issue when the fork is behind upstream/main. + +CI entry: python3 scripts/upstream/notify-merge-needed.py apply ... +Local: python3 scripts/upstream/notify-merge-needed.py --selftest + +This tool never merges, never opens a PR, and never runs an agent. +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any, Protocol + +LABEL = "upstream-merge-needed" +AUTOMATED_LABEL = "automated" +TITLE_PREFIX = "[upstream-merge]" +COMMIT_LIMIT = 30 +MANAGED_MARKER = "managed-by: dev-rules/templates/upstream-merge-notify" +WORKFLOW_RELPATH = ".github/workflows/upstream-merge-notify.yml" +FORBIDDEN_WORKFLOW_NEEDLES = ( + "run-headless-agent", + "ANTHROPIC_AUTH_TOKEN", + "git merge --no-ff", + "gh workflow run", + "claude -p", + "git remote set-url origin", + "merge-state.sh", + "persist-credentials: false", +) +REQUIRED_WORKFLOW_NEEDLES = ( + "unset GITHUB_TOKEN", + "github.token", + MANAGED_MARKER, +) + + +def payload_root() -> Path: + """Return the install/payload root that contains `.github/` and `scripts/`.""" + return Path(__file__).resolve().parents[2] + + +def _run_git(args: list[str]) -> str: + result = subprocess.run( + ["git", *args], + text=True, + capture_output=True, + check=False, + cwd=payload_root(), + ) + if result.returncode != 0: + return "" + return result.stdout.strip() + + +def github_slug_from_remote_url(url: str) -> str: + text = (url or "").strip() + if not text: + return "" + text = re.sub(r"\.git$", "", text) + match = re.search(r"github\.com[:/]([^/]+/[^/]+)$", text) + if match: + return match.group(1) + return "" + + +def detect_repo_names() -> tuple[str, str]: + upstream = ( + os.environ.get("UPSTREAM_REPO") + or github_slug_from_remote_url(_run_git(["remote", "get-url", "upstream"])) + or "upstream" + ) + fork = ( + os.environ.get("FORK_NAME") + or os.environ.get("GITHUB_REPOSITORY") + or github_slug_from_remote_url(_run_git(["remote", "get-url", "origin"])) + or "origin" + ) + return upstream, fork + + +def label_specs(upstream_repo: str, fork_name: str) -> tuple[tuple[str, str, str], ...]: + return ( + (LABEL, "D93F0B", f"{fork_name} is behind {upstream_repo}"), + (AUTOMATED_LABEL, "C5DEF5", "Automated signal"), + ) + + +class IssueHost(Protocol): + def list_open(self, label: str) -> list[int]: ... + def list_open_tracking(self) -> list[int]: ... + def create(self, title: str, body: str, labels: list[str] | None = None) -> int: ... + def update(self, number: int, title: str, body: str) -> None: ... + def comment(self, number: int, body: str) -> None: ... + def close(self, number: int, comment: str) -> None: ... + def attach_labels(self, number: int, specs: list[tuple[str, str, str]]) -> list[str]: ... + + +def is_tracking_title(title: str) -> bool: + return str(title).startswith(TITLE_PREFIX) + + +def label_is_ready( + *, + exists_before: bool, + create_ok: bool, + create_err: str, + exists_after: bool, +) -> bool: + if exists_before or exists_after or create_ok: + return True + return "already exists" in (create_err or "").lower() + + +def decide_action(*, behind: int, existing_open: bool) -> str: + if behind < 0: + raise ValueError("behind must be >= 0") + if behind > 0: + return "update" if existing_open else "create" + return "close" if existing_open else "noop" + + +def render_title(behind: int, *, upstream_repo: str, fork_name: str) -> str: + unit = "commit" if behind == 1 else "commits" + return f"{TITLE_PREFIX} {upstream_repo} is {behind} {unit} ahead of {fork_name}" + + +def parse_merge_tree_conflicts(raw: str) -> list[str]: + """Parse `git merge-tree --write-tree --name-only --no-messages` output.""" + lines = [line for line in raw.splitlines() if line.strip()] + if not lines: + return [] + first = lines[0].strip() + if not re.fullmatch(r"[0-9a-f]{40,64}", first): + raise ValueError("merge-tree output missing leading tree OID") + return lines[1:] + + +def truncate_commits(commits: list[str], limit: int = COMMIT_LIMIT) -> tuple[list[str], int]: + cleaned = [line.rstrip("\n") for line in commits if line.strip()] + if len(cleaned) <= limit: + return cleaned, 0 + return cleaned[:limit], len(cleaned) - limit + + +def render_body( + *, + drift: dict[str, Any], + commits: list[str], + conflicts: list[str], + open_prs: list[dict[str, Any]], + run_url: str, + fork_name: str, + hidden_commit_count: int = 0, +) -> str: + behind = int(drift.get("behind") or 0) + ahead = int(drift.get("ahead") or 0) + upstream_head = str(drift.get("upstream_head") or "") + origin_head = str(drift.get("origin_head") or "") + shown, hidden = truncate_commits(commits) + if hidden_commit_count: + hidden = hidden_commit_count + + commit_block = "\n".join(shown) if shown else "(none)" + if hidden: + commit_block += f"\n... ({hidden} more)" + + if conflicts: + conflict_block = "\n".join(f"- `{path}`" for path in conflicts) + else: + conflict_block = "none (clean merge-tree)" + + if open_prs: + pr_lines = [ + "- Do not treat leftover `merge/upstream-*` PRs as human-approved.", + "", + ] + for pr in open_prs: + number = pr.get("number") + title = pr.get("title") or "" + url = pr.get("url") or "" + head = pr.get("headRefName") or "" + pr_lines.append(f"- #{number} `{head}` {title} {url}".rstrip()) + pr_block = "\n".join(pr_lines) + else: + pr_block = "none" + + return "\n".join( + [ + "> Auto-generated by `.github/workflows/upstream-merge-notify.yml`.", + "> This workflow does **not** merge, open a PR, or run an agent.", + "", + "## Drift", + "", + f"- {fork_name} behind: {behind} commits (`origin/main..upstream/main`)", + f"- {fork_name} ahead: {ahead} commits (`upstream/main..origin/main`)", + f"- upstream/main: `{upstream_head}`", + f"- origin/main: `{origin_head}`", + f"- run: {run_url or 'n/a'}", + "", + "## New upstream commits (`origin/main..upstream/main`)", + "", + "```", + commit_block, + "```", + "", + "## merge-tree conflict files (read-only)", + "", + "If we merged `upstream/main` into `origin/main` now, these files would conflict:", + "", + conflict_block, + "", + "## Open merge/upstream-* PRs", + "", + pr_block, + "", + "## Human next steps", + "", + "1. Decide whether this batch is worth merging. Large or hotspot-heavy batches can wait.", + "2. Follow the `upstream-merge` skill (`.cursor/skills/upstream-merge/SKILL.md`).", + "3. `git fetch upstream && git fetch origin`", + "4. Create `merge/upstream-$(date -u +%Y-%m-%d)` from `origin/main` via `git-worktree-submodule`.", + "5. `git merge --no-ff upstream/main`", + "6. Resolve conflicts, then run the host `scripts/preflight.sh` (or project gates).", + "7. Open a `merge/upstream-*` PR. Reviewer must use **Create a merge commit** (not Squash).", + "", + ] + ) + "\n" + + +def apply( + *, + drift: dict[str, Any], + commits: list[str], + conflicts: list[str], + open_prs: list[dict[str, Any]], + run_url: str, + host: IssueHost, + upstream_repo: str, + fork_name: str, +) -> dict[str, Any]: + if "behind" not in drift: + raise ValueError("drift JSON missing 'behind'") + behind = int(drift["behind"]) + existing = host.list_open_tracking() + action = decide_action(behind=behind, existing_open=bool(existing)) + primary: int | None = existing[0] if existing else None + label_warnings: list[str] = [] + specs = list(label_specs(upstream_repo, fork_name)) + + if action in {"create", "update"}: + title = render_title(behind, upstream_repo=upstream_repo, fork_name=fork_name) + body = render_body( + drift=drift, + commits=commits, + conflicts=conflicts, + open_prs=open_prs, + run_url=run_url, + fork_name=fork_name, + ) + if action == "create": + create_labels: list[str] = [] + if isinstance(host, GhIssueHost): + create_labels = host.labels_ready_for_create(specs) + else: + create_labels = [name for name, _, _ in specs] + primary = host.create(title, body, create_labels or None) + else: + assert primary is not None + host.update(primary, title, body) + host.comment(primary, f"Refreshed by {run_url}: still {behind} commit(s) behind.") + for extra in existing[1:]: + host.close(extra, f"Duplicate of #{primary}.") + if primary is not None: + label_warnings = host.attach_labels(primary, specs) + elif action == "close": + close_comment = ( + f"origin/main now contains upstream/main (`{drift.get('upstream_head') or ''}`). " + f"{run_url}".rstrip() + ) + for number in existing: + host.close(number, close_comment) + primary = None + + return { + "action": action, + "issue": primary, + "label_warnings": label_warnings, + } + + +class GhIssueHost: + def __init__(self, runner: Any | None = None) -> None: + self._run = runner or _run_gh + + def list_open(self, label: str) -> list[int]: + raw = self._run( + [ + "gh", + "issue", + "list", + "--state", + "open", + "--label", + label, + "--json", + "number", + "--jq", + "[.[].number]", + ] + ) + data = json.loads(raw or "[]") + return [int(item) for item in data] + + def list_open_tracking(self) -> list[int]: + raw = self._run( + [ + "gh", + "issue", + "list", + "--state", + "open", + "--limit", + "100", + "--json", + "number,title", + ] + ) + items = json.loads(raw or "[]") + by_title = [ + int(item["number"]) + for item in items + if is_tracking_title(str(item.get("title") or "")) + ] + try: + by_label = self.list_open(LABEL) + except RuntimeError: + by_label = [] + seen: list[int] = [] + for number in by_title + by_label: + if number not in seen: + seen.append(number) + return seen + + def create(self, title: str, body: str, labels: list[str] | None = None) -> int: + body_path = "" + try: + with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as handle: + handle.write(body) + body_path = handle.name + args = [ + "gh", + "issue", + "create", + "--title", + title, + "--body-file", + body_path, + ] + repo = os.environ.get("GH_REPO") or os.environ.get("GITHUB_REPOSITORY") or "" + if repo.strip(): + args.extend(["--repo", repo.strip()]) + ready = [label for label in (labels or []) if label] + if ready: + args.extend(["--label", ",".join(ready)]) + raw = self._run(args) + finally: + if body_path: + Path(body_path).unlink(missing_ok=True) + match = re.search(r"/issues/(\d+)", raw) + if not match: + raise RuntimeError(f"gh issue create did not return an issue URL: {raw!r}") + return int(match.group(1)) + + def update(self, number: int, title: str, body: str) -> None: + self._run(["gh", "issue", "edit", str(number), "--title", title, "--body", body]) + + def comment(self, number: int, body: str) -> None: + self._run(["gh", "issue", "comment", str(number), "--body", body]) + + def close(self, number: int, comment: str) -> None: + self._run(["gh", "issue", "close", str(number), "--comment", comment]) + + def _label_exists(self, name: str) -> bool: + raw = self._run( + [ + "gh", + "label", + "list", + "--search", + name, + "--limit", + "20", + "--json", + "name", + ] + ) + items = json.loads(raw or "[]") + return any(item.get("name") == name for item in items) + + def ensure_label(self, name: str, color: str, description: str) -> bool: + exists_before = self._label_exists(name) + create_ok = True + create_err = "" + if not exists_before: + try: + self._run( + [ + "gh", + "label", + "create", + name, + "--color", + color, + "--description", + description[:100], + ] + ) + except RuntimeError as exc: + create_ok = False + create_err = str(exc) + exists_after = exists_before or self._label_exists(name) + return label_is_ready( + exists_before=exists_before, + create_ok=create_ok, + create_err=create_err, + exists_after=exists_after, + ) + + def attach_labels(self, number: int, specs: list[tuple[str, str, str]]) -> list[str]: + warnings: list[str] = [] + for name, color, description in specs: + if not self.ensure_label(name, color, description): + warnings.append(f"could not ensure label {name!r}") + continue + try: + self._run(["gh", "issue", "edit", str(number), "--add-label", name]) + except RuntimeError as exc: + warnings.append(str(exc)) + return warnings + + def labels_ready_for_create(self, specs: list[tuple[str, str, str]]) -> list[str]: + ready: list[str] = [] + for name, color, description in specs: + if self.ensure_label(name, color, description): + ready.append(name) + return ready + + +def _run_gh(args: list[str], allow_failure: bool = False) -> str: + result = subprocess.run(args, text=True, capture_output=True, check=False) + if result.returncode != 0 and not allow_failure: + raise RuntimeError( + f"{args[0]} failed ({result.returncode}): {result.stderr.strip() or result.stdout.strip()}" + ) + return result.stdout.strip() + + +def _load_json(path: Path, default: Any) -> Any: + if not path.exists(): + return default + text = path.read_text(encoding="utf-8").strip() + if not text: + return default + return json.loads(text) + + +def _load_lines(path: Path) -> list[str]: + if not path.exists(): + return [] + return [line.rstrip("\n") for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + + +def cmd_apply(args: argparse.Namespace) -> int: + drift = _load_json(Path(args.drift), {}) + if not isinstance(drift, dict) or "behind" not in drift: + print("FAIL: --drift must be JSON with a 'behind' field", file=sys.stderr) + return 2 + commits = _load_lines(Path(args.commits)) if args.commits else [] + conflicts_raw = Path(args.conflicts).read_text(encoding="utf-8") if args.conflicts else "" + conflicts = parse_merge_tree_conflicts(conflicts_raw) if conflicts_raw.strip() else [] + open_prs = _load_json(Path(args.open_prs), []) if args.open_prs else [] + if not isinstance(open_prs, list): + print("FAIL: --open-prs must be a JSON array", file=sys.stderr) + return 2 + upstream_repo, fork_name = detect_repo_names() + result = apply( + drift=drift, + commits=commits, + conflicts=conflicts, + open_prs=open_prs, + run_url=args.run_url, + host=GhIssueHost(), + upstream_repo=upstream_repo, + fork_name=fork_name, + ) + print(json.dumps(result, ensure_ascii=False)) + if result.get("label_warnings"): + print("WARN: " + "; ".join(result["label_warnings"]), file=sys.stderr) + return 0 + + +def assert_workflow_is_notify_only(text: str) -> None: + for needle in FORBIDDEN_WORKFLOW_NEEDLES: + if needle in text: + raise AssertionError(f"notify workflow must not contain {needle!r}") + for needle in REQUIRED_WORKFLOW_NEEDLES: + if needle not in text: + raise AssertionError(f"notify workflow must contain {needle!r}") + if "notify-merge-needed.py" not in text: + raise AssertionError("notify workflow must invoke notify-merge-needed.py") + if "check-drift.sh" not in text: + raise AssertionError("notify workflow must invoke check-drift.sh") + + +class MemoryHost: + def __init__( + self, + open_issues: dict[str, list[int]] | None = None, + titles: dict[int, str] | None = None, + attach_fail: bool = False, + ) -> None: + self.open_issues = {key: list(value) for key, value in (open_issues or {}).items()} + self.titles = dict(titles or {}) + for number in self.open_issues.get(LABEL, []): + self.titles.setdefault(number, f"{TITLE_PREFIX} seeded #{number}") + self.created: list[tuple[str, str, list[str]]] = [] + self.updated: list[tuple[int, str, str]] = [] + self.comments: list[tuple[int, str]] = [] + self.closed: list[tuple[int, str]] = [] + self.labels: list[str] = [] + self.attached: list[tuple[int, str]] = [] + self.attach_fail = attach_fail + self._next_number = 9000 + + def list_open(self, label: str) -> list[int]: + return list(self.open_issues.get(label, [])) + + def list_open_tracking(self) -> list[int]: + open_ids: set[int] = set() + for values in self.open_issues.values(): + open_ids.update(values) + return [number for number in sorted(open_ids) if is_tracking_title(self.titles.get(number, ""))] + + def create(self, title: str, body: str, labels: list[str] | None = None) -> int: + self._next_number += 1 + number = self._next_number + self.created.append((title, body, list(labels or []))) + self.titles[number] = title + self.open_issues.setdefault(LABEL, []).append(number) + return number + + def update(self, number: int, title: str, body: str) -> None: + self.updated.append((number, title, body)) + self.titles[number] = title + + def comment(self, number: int, body: str) -> None: + self.comments.append((number, body)) + + def close(self, number: int, comment: str) -> None: + self.closed.append((number, comment)) + for values in self.open_issues.values(): + if number in values: + values.remove(number) + + def attach_labels(self, number: int, specs: list[tuple[str, str, str]]) -> list[str]: + if self.attach_fail: + return [f"could not ensure label {name!r}" for name, _, _ in specs] + for name, _, _ in specs: + self.labels.append(name) + self.attached.append((number, name)) + return [] + + +def run_selftest() -> int: + failures: list[str] = [] + expect_count = 0 + upstream_repo = "example/upstream" + fork_name = "example/fork" + + def expect(name: str, cond: bool) -> None: + nonlocal expect_count + expect_count += 1 + print(f"{'PASS' if cond else 'FAIL'} {name}") + if not cond: + failures.append(name) + + expect("decide_create", decide_action(behind=3, existing_open=False) == "create") + expect("decide_update", decide_action(behind=3, existing_open=True) == "update") + expect("decide_close", decide_action(behind=0, existing_open=True) == "close") + expect("decide_noop", decide_action(behind=0, existing_open=False) == "noop") + expect( + "decide_single_commit_title", + "1 commit ahead" in render_title(1, upstream_repo=upstream_repo, fork_name=fork_name), + ) + expect( + "decide_plural_title", + "2 commits ahead" in render_title(2, upstream_repo=upstream_repo, fork_name=fork_name), + ) + expect( + "slug_from_https", + github_slug_from_remote_url("https://github.com/acme/demo.git") == "acme/demo", + ) + expect( + "slug_from_ssh", + github_slug_from_remote_url("git@github.com:acme/demo.git") == "acme/demo", + ) + + raised = False + try: + decide_action(behind=-1, existing_open=False) + except ValueError: + raised = True + expect("decide_rejects_negative", raised) + + tree = "0123456789abcdef0123456789abcdef01234567\nsrc/a.rs\nsrc/b.ts\n" + expect("parse_conflicts", parse_merge_tree_conflicts(tree) == ["src/a.rs", "src/b.ts"]) + expect("parse_clean_tree", parse_merge_tree_conflicts("0123456789abcdef0123456789abcdef01234567\n") == []) + + body = render_body( + drift={"behind": 4, "ahead": 12, "upstream_head": "abc1234", "origin_head": "def5678"}, + commits=["aaaaaaa first", "bbbbbbb second"], + conflicts=["src/lib.rs"], + open_prs=[ + { + "number": 12, + "title": "leftover", + "url": "https://example.test/12", + "headRefName": "merge/upstream-2026-10-10", + } + ], + run_url="https://example.test/run/1", + fork_name=fork_name, + ) + expect("body_has_range", "origin/main..upstream/main" in body) + expect("body_has_skill", "upstream-merge" in body) + expect("body_has_conflict", "src/lib.rs" in body) + expect("body_warns_leftover_pr", "Do not treat leftover" in body) + expect("body_no_agent", "run-headless-agent" not in body) + + created_host = MemoryHost() + created = apply( + drift={"behind": 5, "ahead": 1, "upstream_head": "up", "origin_head": "fork"}, + commits=["deadbeef feat"], + conflicts=[], + open_prs=[], + run_url="https://example.test/run/2", + host=created_host, + upstream_repo=upstream_repo, + fork_name=fork_name, + ) + expect("apply_create", created["action"] == "create" and created["issue"] == 9001) + + updated_host = MemoryHost(open_issues={LABEL: [10, 11]}) + updated = apply( + drift={"behind": 2, "ahead": 0, "upstream_head": "up", "origin_head": "fork"}, + commits=["c1"], + conflicts=[], + open_prs=[], + run_url="https://example.test/run/3", + host=updated_host, + upstream_repo=upstream_repo, + fork_name=fork_name, + ) + expect("apply_update", updated["action"] == "update" and updated["issue"] == 10) + expect("apply_update_closes_duplicate", any(number == 11 for number, _ in updated_host.closed)) + + closed_host = MemoryHost(open_issues={LABEL: [10]}) + closed = apply( + drift={"behind": 0, "ahead": 3, "upstream_head": "up", "origin_head": "fork"}, + commits=[], + conflicts=[], + open_prs=[], + run_url="https://example.test/run/4", + host=closed_host, + upstream_repo=upstream_repo, + fork_name=fork_name, + ) + expect("apply_close", closed["action"] == "close" and closed["issue"] is None) + + noop_host = MemoryHost() + noop = apply( + drift={"behind": 0, "ahead": 0, "upstream_head": "up", "origin_head": "fork"}, + commits=[], + conflicts=[], + open_prs=[], + run_url="https://example.test/run/5", + host=noop_host, + upstream_repo=upstream_repo, + fork_name=fork_name, + ) + expect("apply_noop", noop["action"] == "noop" and not noop_host.created and not noop_host.closed) + + unlabeled_host = MemoryHost( + open_issues={"other": [12]}, + titles={12: render_title(9, upstream_repo=upstream_repo, fork_name=fork_name)}, + ) + unlabeled = apply( + drift={"behind": 2, "ahead": 0, "upstream_head": "up", "origin_head": "fork"}, + commits=["c1"], + conflicts=[], + open_prs=[], + run_url="https://example.test/run/6", + host=unlabeled_host, + upstream_repo=upstream_repo, + fork_name=fork_name, + ) + expect( + "apply_update_by_title_without_label", + unlabeled["action"] == "update" and unlabeled["issue"] == 12 and not unlabeled_host.created, + ) + + label_fail_host = MemoryHost(attach_fail=True) + label_fail = apply( + drift={"behind": 6, "ahead": 0, "upstream_head": "up", "origin_head": "fork"}, + commits=["deadbeef feat"], + conflicts=[], + open_prs=[], + run_url="https://example.test/run/7", + host=label_fail_host, + upstream_repo=upstream_repo, + fork_name=fork_name, + ) + expect( + "apply_create_survives_label_failure", + label_fail["action"] == "create" and label_fail["issue"] == 9001 and label_fail_host.created, + ) + + root = payload_root() + workflow = root / WORKFLOW_RELPATH + expect("workflow_file_exists", workflow.is_file()) + if workflow.is_file(): + try: + assert_workflow_is_notify_only(workflow.read_text(encoding="utf-8")) + workflow_ok = True + workflow_err = "" + except AssertionError as exc: + workflow_ok = False + workflow_err = str(exc) + expect("workflow_is_notify_only", workflow_ok) + if not workflow_ok: + print(f"FAIL workflow_is_notify_only detail: {workflow_err}") + + expect("check_drift_exists", (root / "scripts/upstream/check-drift.sh").is_file()) + expect("drift_lib_exists", (root / "scripts/lib/upstream-drift.sh").is_file()) + expect("upstream_url_conf_exists", (root / "scripts/lib/upstream-url.conf").is_file()) + if (root / "scripts/lib/upstream-drift.sh").is_file(): + lib_text = (root / "scripts/lib/upstream-drift.sh").read_text(encoding="utf-8") + expect("drift_lib_managed", MANAGED_MARKER in lib_text) + if (root / "scripts/lib/upstream-url.conf").is_file(): + conf = (root / "scripts/lib/upstream-url.conf").read_text(encoding="utf-8").strip() + expect("upstream_url_conf_has_placeholder_or_url", bool(conf)) + + passed = expect_count - len(failures) + print(f"notify-merge-needed self-test ({passed}/{expect_count} cases passed)") + return 1 if failures else 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Create or refresh the upstream-merge-needed issue.") + parser.add_argument("--selftest", action="store_true") + sub = parser.add_subparsers(dest="command") + apply_parser = sub.add_parser("apply", help="create/update/close the tracking issue via gh") + apply_parser.add_argument("--drift", required=True, help="path to check-drift.sh --json output") + apply_parser.add_argument("--commits", default="", help="path to git log origin/main..upstream/main") + apply_parser.add_argument("--conflicts", default="", help="path to git merge-tree --name-only output") + apply_parser.add_argument("--open-prs", default="", help="path to leftover merge/upstream-* PR JSON") + apply_parser.add_argument("--run-url", default="", help="CI run URL written into the issue body") + args = parser.parse_args() + + if args.selftest: + return run_selftest() + if args.command == "apply": + return cmd_apply(args) + parser.error("one of apply / --selftest is required") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) From 4e7141428f6efd20d6ad37034360fdacb4ff432e Mon Sep 17 00:00:00 2001 From: chilli Date: Thu, 22 Oct 2026 10:32:49 +0800 Subject: [PATCH 7/7] Merge pull request #10 from youxuanxue/feat/cc-switch-claude-catalog-and-work-task-fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: Claude 合并 cc-switch 模型目录,并修 work task 无 Git/重连偏好 --- src-tauri/src/acp/connection.rs | 140 +++++- src-tauri/src/acp/session_state.rs | 6 + src-tauri/src/commands/acp.rs | 451 +++++++++++++++++- src-tauri/src/web/handlers/acp.rs | 12 +- src-tauri/src/work_task/engine.rs | 35 +- src-tauri/src/work_task/git.rs | 156 +++++- .../settings/acp-agent-settings.tsx | 16 +- 7 files changed, 800 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index bc31020887..6acc7463a3 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -2476,6 +2476,10 @@ pub async fn spawn_agent_connection( owner_window_label.clone(), None, // folder_id 由后续 prompt handler 在首次 send 时绑定 (Phase 2) ); + if agent_type == AgentType::ClaudeCode { + initial_state.claude_model_catalog = + crate::commands::acp::cc_switch_claude_model_catalog(&runtime_env); + } // Install the SessionStarted dedup signal BEFORE wrapping into Arc so the // first event (StatusChanged{Connecting} below) doesn't race with the @@ -3622,6 +3626,75 @@ fn map_session_config_options( .collect() } +/// Add models from the cc-switch provider catalog to Claude's model selector. +/// The ACP adapter intentionally advertises only its built-in aliases, while a +/// gateway may expose concrete ids such as `claude-opus-5-5`. Keep the agent's +/// rows and current value intact, append only missing ids, and put the injected +/// rows in their own group so the UI makes their source clear. +fn merge_claude_model_catalog( + options: &mut [SessionConfigOption], + catalog: &[(String, String, Option)], +) { + if catalog.is_empty() { + return; + } + let Some(option) = options.iter_mut().find(|option| { + matches!(option.category, Some(SessionConfigOptionCategory::Model)) + || option.id.to_string() == MODEL_CONFIG_OPTION_ID + }) else { + return; + }; + let SessionConfigKind::Select(select) = &mut option.kind else { + return; + }; + let has = |value: &str| match &select.options { + SessionConfigSelectOptions::Ungrouped(rows) => + rows.iter().any(|row| row.value.to_string() == value), + SessionConfigSelectOptions::Grouped(groups) => groups + .iter() + .flat_map(|group| group.options.iter()) + .any(|row| row.value.to_string() == value), + _ => false, + }; + let rows: Vec = catalog + .iter() + .filter(|(id, _, _)| !has(id)) + .map(|(id, name, description)| { + SessionConfigSelectOption::new(id.clone(), name.clone()).description(description.clone()) + }) + .collect(); + if rows.is_empty() { + return; + } + // Always keep injected ids in a labeled group so the UI distinguishes + // gateway catalog entries from the agent's built-in aliases — including + // when the adapter originally advertised an ungrouped list. + select.options = match std::mem::replace( + &mut select.options, + SessionConfigSelectOptions::Ungrouped(Vec::new()), + ) { + SessionConfigSelectOptions::Ungrouped(existing) => SessionConfigSelectOptions::Grouped( + vec![ + SessionConfigSelectGroup::new("built-in", "Built-in", existing), + SessionConfigSelectGroup::new("cc-switch", "cc-switch provider", rows), + ], + ), + SessionConfigSelectOptions::Grouped(mut groups) => { + groups.push(SessionConfigSelectGroup::new( + "cc-switch", + "cc-switch provider", + rows, + )); + SessionConfigSelectOptions::Grouped(groups) + } + _ => SessionConfigSelectOptions::Grouped(vec![SessionConfigSelectGroup::new( + "cc-switch", + "cc-switch provider", + rows, + )]), + }; +} + /// Config-option ids a launch carrying `runtime_env` has pinned, so the agent /// will reject any attempt to change them (see /// [`SessionState::env_pinned_config_option_ids`]). @@ -3704,8 +3777,18 @@ async fn emit_session_config_options_values( // Drop what this launch froze BEFORE mapping, so every producer of this // event — establishment, the answer to a set, and the agent's own // `config_option_update` push — is filtered by one rule. - let pinned = state.read().await.env_pinned_config_option_ids.clone(); - let visible = visible_config_options(&pinned, config_options); + let (pinned, agent_type, catalog) = { + let guard = state.read().await; + ( + guard.env_pinned_config_option_ids.clone(), + guard.agent_type, + guard.claude_model_catalog.clone(), + ) + }; + let mut visible = visible_config_options(&pinned, config_options); + if agent_type == AgentType::ClaudeCode { + merge_claude_model_catalog(&mut visible, &catalog); + } emit_with_state( state, emitter, @@ -5442,6 +5525,11 @@ async fn apply_and_emit_session_config_options( preferred_config_values: &BTreeMap, initial_config_options: Vec, ) { + let mut initial_config_options = initial_config_options; + if agent_type == AgentType::ClaudeCode { + let catalog = state.read().await.claude_model_catalog.clone(); + merge_claude_model_catalog(&mut initial_config_options, &catalog); + } // Every establishment starts from an empty ledger. `SessionState` outlives a // fork transition, so without this the child would inherit — and defend — // whatever the parent asserted, on paths that never write it back: Grok's @@ -20985,6 +21073,54 @@ mod tests { } } + #[test] + fn merge_claude_model_catalog_promotes_ungrouped_and_skips_duplicates() { + let mut options: Vec = vec![serde_json::from_value(serde_json::json!({ + "id": "model", + "name": "Model", + "category": "model", + "type": "select", + "currentValue": "sonnet", + "options": [ + {"value": "sonnet", "name": "Sonnet"}, + {"value": "opus", "name": "Opus"}, + ], + })) + .expect("fixture")]; + + merge_claude_model_catalog( + &mut options, + &[ + ( + "sonnet".to_string(), + "Sonnet".to_string(), + Some("cc-switch provider catalog".to_string()), + ), + ( + "claude-opus-5-5".to_string(), + "Opus 5.5".to_string(), + Some("cc-switch provider catalog".to_string()), + ), + ], + ); + + let SessionConfigKind::Select(select) = &options[0].kind else { + panic!("expected select"); + }; + let SessionConfigSelectOptions::Grouped(groups) = &select.options else { + panic!("ungrouped catalog merge must promote to grouped: {:?}", select.options); + }; + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].group.to_string(), "built-in"); + assert_eq!(groups[1].group.to_string(), "cc-switch"); + let injected: Vec = groups[1] + .options + .iter() + .map(|row| row.value.to_string()) + .collect(); + assert_eq!(injected, vec!["claude-opus-5-5"]); + } + // ── PermissionQueue (#442) ────────────────────────────────────────────── // // The queue is what stops N concurrent `session/request_permission`s from diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index 7b066b796b..1befea1d62 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -342,6 +342,11 @@ pub struct SessionState { pub modes: Option, pub current_mode: Option, pub config_options: Option>, + /// Claude model ids supplied by the cc-switch unified provider catalog. + /// These are merged into Claude's ACP model selector at establishment so + /// a gateway's real catalog (for example `claude-opus-5-5`) remains + /// selectable even when the adapter only advertises family aliases. + pub claude_model_catalog: Vec<(String, String, Option)>, /// Grok only: per-model reasoning-effort specs, parsed from the top-level /// `models` of the session-establishment response (guaranteed on /// `session/new`; opportunistic on resume/fork) and refreshed by each model @@ -755,6 +760,7 @@ impl SessionState { modes: None, current_mode: None, config_options: None, + claude_model_catalog: Vec::new(), grok_model_specs: None, grok_catalog_broadcast: None, kiro_reasoning: None, diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 77a0029f5a..0ce7573fcb 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use serde::{Deserialize, Serialize}; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; #[cfg(feature = "tauri-runtime")] use tauri::{Manager, State}; @@ -10574,6 +10575,112 @@ pub(crate) fn build_runtime_env_from_setting( merged } +/// Read the Claude model ids from the cc-switch unified Pi catalog that backs +/// the same provider credentials. cc-switch keeps the catalog in Pi's native +/// `models.json` even when Claude Code is the active client; matching both the +/// endpoint and key prevents another gateway's models from leaking into the +/// Claude picker when several providers are configured. +pub(crate) fn cc_switch_claude_model_catalog( + runtime_env: &BTreeMap, +) -> Vec<(String, String, Option)> { + let base_url = runtime_env + .get("ANTHROPIC_BASE_URL") + .map(|value| normalize_catalog_url(value)); + let api_key = runtime_env.get("ANTHROPIC_AUTH_TOKEN"); + let Some(base_url) = base_url.filter(|value| !value.is_empty()) else { + return Vec::new(); + }; + let path = pi_agent_dir_for_env(runtime_env).join("models.json"); + let Ok(raw) = std::fs::read_to_string(path) else { + return Vec::new(); + }; + let Ok(root) = serde_json::from_str::(&raw) else { + return Vec::new(); + }; + let Some(providers) = root.get("providers").and_then(serde_json::Value::as_object) else { + return Vec::new(); + }; + + // cc-switch may keep the active credential in its own auth store while + // Codeg's model-provider row still has the same gateway endpoint paired + // with a different client token. Prefer an exact credential match when one + // exists, but fall back to the endpoint catalog so the unified source is + // still visible instead of silently producing an empty picker. + let has_exact_key_match = api_key.is_some_and(|expected_key| { + providers.values().any(|provider| { + provider + .get("baseUrl") + .and_then(serde_json::Value::as_str) + .map(normalize_catalog_url) + .is_some_and(|provider_url| provider_url == base_url) + && provider + .get("apiKey") + .and_then(serde_json::Value::as_str) + .is_some_and(|provider_key| provider_key == expected_key) + }) + }); + + let mut rows = Vec::new(); + for provider in providers.values() { + let Some(provider_url) = provider + .get("baseUrl") + .and_then(serde_json::Value::as_str) + .map(normalize_catalog_url) + else { + continue; + }; + if provider_url != base_url { + continue; + } + if has_exact_key_match { + let Some(actual) = api_key else { + continue; + }; + if provider + .get("apiKey") + .and_then(serde_json::Value::as_str) + != Some(actual) + { + continue; + } + } + let Some(models) = provider.get("models").and_then(serde_json::Value::as_array) else { + continue; + }; + for model in models { + let Some(id) = model.get("id").and_then(serde_json::Value::as_str) else { + continue; + }; + // Claude's picker should contain models that the Claude protocol + // gateway can serve. Keep the catalog focused on Claude ids while + // retaining AWS-style `us.anthropic.claude-*` entries. + if !(id.starts_with("claude-") || id.contains(".anthropic.claude-")) { + continue; + } + let name = model + .get("name") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(id) + .to_string(); + rows.push((id.to_string(), name, Some("cc-switch provider catalog".to_string()))); + } + } + rows.sort_by(|a, b| a.0.cmp(&b.0)); + rows.dedup_by(|a, b| a.0 == b.0); + rows +} + +fn normalize_catalog_url(value: &str) -> String { + value + .trim() + .trim_end_matches('/') + .strip_suffix("/v1") + .unwrap_or_else(|| value.trim().trim_end_matches('/')) + .trim_end_matches('/') + .to_string() +} + /// Resolve model provider credentials into runtime env vars if `model_provider_id` is set. pub(crate) async fn apply_model_provider_env( agent_type: AgentType, @@ -11119,6 +11226,61 @@ pub(crate) async fn build_session_runtime_env( build_runtime_env_for_agent(db, agent_type, session_id, data_dir, false).await } +/// Work-task sessions carry their own ACP selections. On a reconnect the chat +/// surface also supplies the user's global selector preferences; letting those +/// win would silently turn a task saved as `high` back into the global default +/// (`medium`). Resolve the task by the external ACP session id and mirror +/// [`crate::work_task::engine`]'s `effective_agent_config`: an explicit task +/// agent override keeps the task's mode/config; an inherited agent (`None`) +/// keeps the folder work-task settings that launch already used. +pub(crate) async fn task_session_preferences( + db: &AppDatabase, + agent_type: AgentType, + session_id: Option<&str>, + preferred_mode_id: Option, + preferred_config_values: BTreeMap, +) -> (Option, BTreeMap) { + let Some(session_id) = session_id.map(str::trim).filter(|id| !id.is_empty()) else { + return (preferred_mode_id, preferred_config_values); + }; + let agent = agent_type.as_wire(); + let conversation = crate::db::entities::conversation::Entity::find() + .filter(crate::db::entities::conversation::Column::ExternalId.eq(session_id)) + .filter(crate::db::entities::conversation::Column::AgentType.eq(agent.as_ref())) + .one(&db.conn) + .await + .ok() + .flatten(); + let Some(conversation) = conversation else { + return (preferred_mode_id, preferred_config_values); + }; + let task = crate::db::entities::work_task::Entity::find() + .filter(crate::db::entities::work_task::Column::ConversationId.eq(conversation.id)) + .one(&db.conn) + .await + .ok() + .flatten(); + let Some(task) = task else { + return (preferred_mode_id, preferred_config_values); + }; + let Ok(config) = serde_json::from_str::(&task.config) else { + return (preferred_mode_id, preferred_config_values); + }; + match config.agent_type.as_deref() { + Some(configured) if configured == agent.as_ref() => { + (config.mode_id, config.config_values) + } + Some(_) => (preferred_mode_id, preferred_config_values), + None => { + let settings = + crate::db::service::work_task_service::settings_get_effective(&db.conn, task.folder_id) + .await + .unwrap_or_default(); + (settings.mode_id, settings.config_values) + } + } +} + /// Settings may inspect Pi's local model catalog while the agent is disabled. /// Reuse the exact launch environment, bypassing only the connection permission /// gate; this path starts no ACP session and sends no prompt. @@ -11372,6 +11534,14 @@ pub async fn acp_connect( // can prompt the user to install it from Agent Settings. verify_agent_installed(agent_type).await?; + let (preferred_mode_id, preferred_config_values) = task_session_preferences( + &db, + agent_type, + session_id.as_deref(), + preferred_mode_id, + preferred_config_values.unwrap_or_default(), + ) + .await; let emitter = EventEmitter::Tauri(app_handle); manager .spawn_agent( @@ -11382,7 +11552,7 @@ pub async fn acp_connect( window.label().to_string(), emitter, preferred_mode_id, - preferred_config_values.unwrap_or_default(), + preferred_config_values, ) .await } @@ -21290,4 +21460,283 @@ model = "gpt" ); assert_eq!(env.get("CLINE_PROVIDER").map(String::as_str), Some("cline-pass")); } + + #[test] + fn normalize_catalog_url_strips_trailing_slash_and_v1() { + assert_eq!( + normalize_catalog_url(" https://gw.example/v1/ "), + "https://gw.example" + ); + assert_eq!( + normalize_catalog_url("https://gw.example/v1"), + "https://gw.example" + ); + assert_eq!( + normalize_catalog_url("https://gw.example/"), + "https://gw.example" + ); + } + + #[test] + fn cc_switch_claude_model_catalog_matches_key_filters_non_claude_and_dedups() { + let dir = tempfile::tempdir().expect("tempdir"); + let models = dir.path().join("models.json"); + std::fs::write( + &models, + serde_json::json!({ + "providers": { + "exact": { + "baseUrl": "https://gw.example/v1", + "apiKey": "sk-exact", + "models": [ + {"id": "claude-opus-5-5", "name": "Opus"}, + {"id": "gpt-4o", "name": "GPT"}, + {"id": "us.anthropic.claude-sonnet-5", "name": "Sonnet"}, + {"id": "claude-opus-5-5", "name": "Opus dup"}, + ] + }, + "other-key": { + "baseUrl": "https://gw.example/v1/", + "apiKey": "sk-other", + "models": [ + {"id": "claude-haiku-5-5", "name": "Haiku"} + ] + } + } + }) + .to_string(), + ) + .expect("write models.json"); + + let mut env = BTreeMap::new(); + env.insert( + "PI_CODING_AGENT_DIR".to_string(), + dir.path().to_string_lossy().to_string(), + ); + env.insert( + "ANTHROPIC_BASE_URL".to_string(), + "https://gw.example/v1".to_string(), + ); + env.insert( + "ANTHROPIC_AUTH_TOKEN".to_string(), + "sk-exact".to_string(), + ); + + let rows = cc_switch_claude_model_catalog(&env); + let ids: Vec<&str> = rows.iter().map(|(id, _, _)| id.as_str()).collect(); + assert_eq!( + ids, + vec!["claude-opus-5-5", "us.anthropic.claude-sonnet-5"], + "exact key match must drop the other-key provider and non-claude ids: {rows:?}" + ); + } + + #[test] + fn cc_switch_claude_model_catalog_falls_back_to_endpoint_when_key_mismatches() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("models.json"), + serde_json::json!({ + "providers": { + "a": { + "baseUrl": "https://gw.example", + "apiKey": "sk-a", + "models": [{"id": "claude-opus-5-5", "name": "Opus"}] + } + } + }) + .to_string(), + ) + .expect("write"); + + let mut env = BTreeMap::new(); + env.insert( + "PI_CODING_AGENT_DIR".to_string(), + dir.path().to_string_lossy().to_string(), + ); + env.insert( + "ANTHROPIC_BASE_URL".to_string(), + "https://gw.example/v1".to_string(), + ); + env.insert( + "ANTHROPIC_AUTH_TOKEN".to_string(), + "sk-codeg-client".to_string(), + ); + + let rows = cc_switch_claude_model_catalog(&env); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].0, "claude-opus-5-5"); + } + + #[tokio::test] + async fn task_session_preferences_prefer_explicit_task_override() { + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + use sea_orm::ActiveModelTrait; + + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/task-prefs-explicit").await; + let conv = crate::db::service::conversation_service::create( + &db.conn, + folder_id, + AgentType::ClaudeCode, + None, + None, + ) + .await + .expect("conversation"); + crate::db::service::conversation_service::bind_external_id( + &db.conn, + conv.id, + "sess-explicit", + &[], + ) + .await + .expect("bind"); + let task = crate::db::service::work_task_service::create( + &db.conn, + crate::models::WorkTaskDraft { + folder_id, + title: "t".to_string(), + config: serde_json::json!({ + "display_text": "t", + "prompt_blocks": [{"type":"text","text":"t"}], + "agent_type": "claude_code", + "mode_id": "high", + "config_values": {"model": "claude-opus-5-5"}, + }), + }, + ) + .await + .expect("task"); + let model = crate::db::service::work_task_service::get_model(&db.conn, task.id) + .await + .expect("model"); + let mut active: crate::db::entities::work_task::ActiveModel = model.into(); + active.conversation_id = sea_orm::Set(Some(conv.id)); + active.update(&db.conn).await.expect("link"); + + let mut global = BTreeMap::new(); + global.insert("model".to_string(), "claude-sonnet-5-5".to_string()); + let (mode, config) = task_session_preferences( + &db, + AgentType::ClaudeCode, + Some("sess-explicit"), + Some("medium".to_string()), + global, + ) + .await; + assert_eq!(mode.as_deref(), Some("high")); + assert_eq!(config.get("model").map(String::as_str), Some("claude-opus-5-5")); + } + + #[tokio::test] + async fn task_session_preferences_inherit_folder_settings_when_agent_unset() { + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + use sea_orm::ActiveModelTrait; + + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/task-prefs-inherit").await; + crate::db::service::work_task_service::settings_set( + &db.conn, + folder_id, + &crate::models::WorkTaskFolderSettings { + default_agent_type: Some("claude_code".to_string()), + mode_id: Some("high".to_string()), + config_values: BTreeMap::from([( + "model".to_string(), + "claude-opus-5-5".to_string(), + )]), + ..Default::default() + }, + ) + .await + .expect("settings"); + let conv = crate::db::service::conversation_service::create( + &db.conn, + folder_id, + AgentType::ClaudeCode, + None, + None, + ) + .await + .expect("conversation"); + crate::db::service::conversation_service::bind_external_id( + &db.conn, + conv.id, + "sess-inherit", + &[], + ) + .await + .expect("bind"); + let task = crate::db::service::work_task_service::create( + &db.conn, + crate::models::WorkTaskDraft { + folder_id, + title: "t".to_string(), + config: serde_json::json!({ + "display_text": "t", + "prompt_blocks": [{"type":"text","text":"t"}], + }), + }, + ) + .await + .expect("task"); + let model = crate::db::service::work_task_service::get_model(&db.conn, task.id) + .await + .expect("model"); + let mut active: crate::db::entities::work_task::ActiveModel = model.into(); + active.conversation_id = sea_orm::Set(Some(conv.id)); + active.update(&db.conn).await.expect("link"); + + let mut global = BTreeMap::new(); + global.insert("model".to_string(), "claude-sonnet-5-5".to_string()); + let (mode, config) = task_session_preferences( + &db, + AgentType::ClaudeCode, + Some("sess-inherit"), + Some("medium".to_string()), + global, + ) + .await; + assert_eq!(mode.as_deref(), Some("high")); + assert_eq!(config.get("model").map(String::as_str), Some("claude-opus-5-5")); + } + + #[tokio::test] + async fn task_session_preferences_leave_non_task_sessions_alone() { + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/task-prefs-plain").await; + let conv = crate::db::service::conversation_service::create( + &db.conn, + folder_id, + AgentType::ClaudeCode, + None, + None, + ) + .await + .expect("conversation"); + crate::db::service::conversation_service::bind_external_id( + &db.conn, + conv.id, + "sess-plain", + &[], + ) + .await + .expect("bind"); + + let mut global = BTreeMap::new(); + global.insert("model".to_string(), "claude-sonnet-5-5".to_string()); + let (mode, config) = task_session_preferences( + &db, + AgentType::ClaudeCode, + Some("sess-plain"), + Some("medium".to_string()), + global.clone(), + ) + .await; + assert_eq!(mode.as_deref(), Some("medium")); + assert_eq!(config, global); + } } diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index 72109750db..43f2f5e84c 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -99,6 +99,14 @@ pub async fn acp_connect( .await .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + let (preferred_mode_id, preferred_config_values) = acp_commands::task_session_preferences( + db, + params.agent_type, + params.session_id.as_deref(), + params.preferred_mode_id, + params.preferred_config_values.unwrap_or_default(), + ) + .await; let emitter = state.emitter.clone(); let connection_id = manager .spawn_agent( @@ -108,8 +116,8 @@ pub async fn acp_connect( runtime_env, "web".to_string(), emitter, - params.preferred_mode_id, - params.preferred_config_values.unwrap_or_default(), + preferred_mode_id, + preferred_config_values, ) .await .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; diff --git a/src-tauri/src/work_task/engine.rs b/src-tauri/src/work_task/engine.rs index ae5f0d0806..2c6b2ea1b0 100644 --- a/src-tauri/src/work_task/engine.rs +++ b/src-tauri/src/work_task/engine.rs @@ -1681,6 +1681,38 @@ impl TaskEngine { return Ok(wt); } + // A project folder can be added to Codeg before it has a Git + // repository. Work tasks need a branch-backed HEAD for their + // worktrees, so make the first commit from the folder's current files + // before resolving any task-specific base (including a PR base). + let mut project_head = resolve_git_head(&root.path) + .await + .map_err(|e| e.to_string())?; + let project_needs_initial_commit = !project_head.is_repo + || (project_head.branch.is_some() + && task_git::rev_parse(&root.path, "HEAD").await.is_err()); + if project_needs_initial_commit { + // Multiple tasks may be launched from the same folder. Serialize + // this one-time mutation and re-check under the lock so they do + // not race into multiple root commits. + let folder_lock = self.folder_lock(task.folder_id).await; + let _folder_guard = folder_lock.lock().await; + project_head = resolve_git_head(&root.path) + .await + .map_err(|e| e.to_string())?; + if !project_head.is_repo + || (project_head.branch.is_some() + && task_git::rev_parse(&root.path, "HEAD").await.is_err()) + { + task_git::init_with_initial_commit(&self.db.conn, &root.path, "Initial commit") + .await + .map_err(|e| format!("could not initialize project repository: {e}"))?; + project_head = resolve_git_head(&root.path) + .await + .map_err(|e| e.to_string())?; + } + } + // Where the task's branch starts, and what its diff is measured // against. Normally both are the project folder's current HEAD; a task // created FOR a particular branch starts at that branch's tip instead, @@ -1712,8 +1744,7 @@ impl TaskEngine { (branch, tip.clone(), tip) } None => { - let head = resolve_git_head(&root.path).await.map_err(|e| e.to_string())?; - let base_branch = head.branch.ok_or_else(|| { + let base_branch = project_head.branch.ok_or_else(|| { "project folder is not on a branch (detached HEAD?)".to_string() })?; let base_sha = task_git::rev_parse(&root.path, "HEAD") diff --git a/src-tauri/src/work_task/git.rs b/src-tauri/src/work_task/git.rs index e2029e58aa..9ccf5d494d 100644 --- a/src-tauri/src/work_task/git.rs +++ b/src-tauri/src/work_task/git.rs @@ -106,6 +106,97 @@ pub async fn commit_all( Ok(true) } +/// Secret-shaped paths that must not land in an auto-created first commit +/// when the project folder has no usable `.gitignore` yet. +const AUTO_INIT_SECRET_GITIGNORE: &[&str] = &[ + ".env", + ".env.*", + "*.pem", + "*.key", + "id_rsa", + "id_ed25519", + "credentials.json", +]; + +/// Ensure common secret patterns are ignored before `git add -A` on an +/// auto-initialized project folder. Appends missing entries; never strips +/// user-authored rules. +fn ensure_secret_gitignore_entries(path: &str) -> Result<(), AppCommandError> { + let gi_path = std::path::Path::new(path).join(".gitignore"); + let existing = std::fs::read_to_string(&gi_path).unwrap_or_default(); + let present: std::collections::HashSet<&str> = existing + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .collect(); + let missing: Vec<&str> = AUTO_INIT_SECRET_GITIGNORE + .iter() + .copied() + .filter(|entry| !present.contains(entry)) + .collect(); + if missing.is_empty() { + return Ok(()); + } + let mut content = existing; + if !content.is_empty() && !content.ends_with('\n') { + content.push('\n'); + } + if content.is_empty() { + content.push_str("# Added by Codeg work-task auto-init\n"); + } else { + content.push_str("\n# Added by Codeg work-task auto-init\n"); + } + for entry in missing { + content.push_str(entry); + content.push('\n'); + } + std::fs::write(&gi_path, content).map_err(AppCommandError::io)?; + tracing::info!( + path, + "work_task auto-init: ensured secret patterns in .gitignore before first commit" + ); + Ok(()) +} + +/// Initialize a project directory and create the first commit from its +/// current contents. Callers should invoke it only after confirming the +/// directory is either not a Git work tree or has an unborn branch. +pub async fn init_with_initial_commit( + conn: &sea_orm::DatabaseConnection, + path: &str, + message: &str, +) -> Result<(), AppCommandError> { + let init = run_git(path, &["init"]).await?; + if !init.status.success() { + return Err(git_command_error("init", &init.stderr)); + } + + // Fresh machines / CI often have no global git identity; the auto-init + // commit must not depend on one. Only fill local config when missing so + // an existing repo's author settings stay untouched. + let has_name = run_git(path, &["config", "--get", "user.name"]).await?; + if !has_name.status.success() { + let set_name = run_git(path, &["config", "user.name", "Codeg Work Task"]).await?; + if !set_name.status.success() { + return Err(git_command_error("config user.name", &set_name.stderr)); + } + let set_email = + run_git(path, &["config", "user.email", "codeg-work-task@localhost"]).await?; + if !set_email.status.success() { + return Err(git_command_error("config user.email", &set_email.stderr)); + } + } + + ensure_secret_gitignore_entries(path)?; + + let add = run_git(path, &["add", "-A"]).await?; + if !add.status.success() { + return Err(git_command_error("add -A", &add.stderr)); + } + + commit_staged_with_allow_empty(conn, path, message).await +} + /// Commit whatever is staged (used by commit-all and the squash landing). /// Resolves the commit author from the matching configured account, like /// `git_commit_core`. @@ -113,6 +204,23 @@ pub async fn commit_staged( conn: &sea_orm::DatabaseConnection, path: &str, message: &str, +) -> Result<(), AppCommandError> { + commit_staged_inner(conn, path, message, false).await +} + +async fn commit_staged_with_allow_empty( + conn: &sea_orm::DatabaseConnection, + path: &str, + message: &str, +) -> Result<(), AppCommandError> { + commit_staged_inner(conn, path, message, true).await +} + +async fn commit_staged_inner( + conn: &sea_orm::DatabaseConnection, + path: &str, + message: &str, + allow_empty: bool, ) -> Result<(), AppCommandError> { let author_override = crate::git_credential::resolve_commit_author(path, conn).await; let mut cmd = crate::process::tokio_command("git"); @@ -124,7 +232,11 @@ pub async fn commit_staged( &format!("user.email={email}"), ]); } - cmd.args(["commit", "-m", message]).current_dir(path); + cmd.args(["commit"]); + if allow_empty { + cmd.arg("--allow-empty"); + } + cmd.args(["-m", message]).current_dir(path); let out = cmd.output().await.map_err(AppCommandError::io)?; if !out.status.success() { return Err(git_command_error("commit", &out.stderr)); @@ -1485,4 +1597,46 @@ mod tests { "one\ntwo\n" ); } + + #[tokio::test] + async fn init_with_initial_commit_creates_head_and_keeps_dotenv_untracked() { + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().to_str().expect("utf-8"); + std::fs::write(dir.path().join("readme.txt"), "hello\n").expect("readme"); + std::fs::write(dir.path().join(".env"), "SECRET=1\n").expect("dotenv"); + + init_with_initial_commit(&db.conn, path, "Initial commit") + .await + .expect("auto-init"); + + let head = rev_parse(path, "HEAD").await.expect("HEAD after init"); + assert!(!head.is_empty()); + let tracked = run_git(path, &["ls-files"]).await.expect("ls-files"); + assert!(tracked.status.success(), "ls-files failed"); + let tracked = String::from_utf8_lossy(&tracked.stdout); + assert!( + tracked.contains("readme.txt"), + "project files should be in the first commit: {tracked}" + ); + assert!( + !tracked.contains(".env"), + ".env must stay untracked after auto-init: {tracked}" + ); + let gi = std::fs::read_to_string(dir.path().join(".gitignore")).expect("gitignore"); + assert!( + gi.lines().any(|line| line.trim() == ".env"), + "auto-init must record .env in .gitignore: {gi}" + ); + + // Second ensure pass (via another folder) must not duplicate entries + // when patterns are already present. + ensure_secret_gitignore_entries(path).expect("idempotent ensure"); + let gi2 = std::fs::read_to_string(dir.path().join(".gitignore")).expect("gitignore"); + assert_eq!( + gi2.matches(".env\n").count(), + gi.matches(".env\n").count(), + "secret ignore entries must not duplicate" + ); + } } diff --git a/src/components/settings/acp-agent-settings.tsx b/src/components/settings/acp-agent-settings.tsx index f1da09d578..97c0e450d7 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -10621,7 +10621,7 @@ supports_websockets = true`} event.target.value ) }} - placeholder="claude-sonnet-5-5" + placeholder="provider model id" /> @@ -12159,7 +12159,7 @@ supports_websockets = true`} event.target.value ) }} - placeholder="claude-sonnet-5-5" + placeholder="provider model id" />
@@ -12178,7 +12178,7 @@ supports_websockets = true`} event.target.value ) }} - placeholder="claude-opus-5-5" + placeholder="provider model id" />
@@ -12197,7 +12197,7 @@ supports_websockets = true`} event.target.value ) }} - placeholder="claude-haiku-5-5" + placeholder="provider model id" />
@@ -12216,7 +12216,7 @@ supports_websockets = true`} event.target.value ) }} - placeholder="claude-sonnet-5-5" + placeholder="provider model id" />
@@ -12235,7 +12235,7 @@ supports_websockets = true`} event.target.value ) }} - placeholder="claude-opus-5-5" + placeholder="provider model id" />
@@ -12254,7 +12254,7 @@ supports_websockets = true`} event.target.value ) }} - placeholder="claude-fable-5-1" + placeholder="provider model id" />
@@ -12279,7 +12279,7 @@ supports_websockets = true`} event.target.value ) }} - placeholder="my-gateway/claude-opus-5-5" + placeholder="custom provider model id" />