From 66c88ddfaef0d27c05d7d31c12decec6e34459d8 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 21:11:55 -0700 Subject: [PATCH] feat(runtime): allow thread workspace updates Harvest the UpdateThreadRequest workspace field from PR #2640 while keeping the engine-cache correctness fix: PATCH /v1/threads/{id} now persists workspace changes, emits the workspace change in thread.updated, rejects empty paths, rejects workspace changes while a turn is active, and evicts idle cached engines so the next turn starts in the new workspace. Validation: cargo fmt --all -- --check; git diff --check; cargo test -p codewhale-tui --bin codewhale-tui --locked update_thread_workspace -- --nocapture; cargo clippy -p codewhale-tui --locked -- -D warnings; python3 scripts/check-coauthor-trailers.py --author-map .github/AUTHOR_MAP --range origin/main..HEAD --check-authors. Harvested from PR #2640 by @gaord. Co-authored-by: gaord <9567937+gaord@users.noreply.github.com> --- CHANGELOG.md | 6 +- crates/tui/src/runtime_threads.rs | 208 ++++++++++++++++++++++++++++++ docs/V0_9_0_EXECUTION_MAP.md | 3 +- 3 files changed, 215 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ed859a5..069fbbfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `/config` now reports the canonical `~/.codewhale/settings.toml` path for TUI settings while still reading legacy DeepSeek-branded settings fallbacks and migrating them into the CodeWhale home on load. +- `PATCH /v1/threads/{id}` can now update a thread's persisted workspace for + GUI/runtime clients. Workspace changes reject active turns and evict idle + cached engines so the next turn starts in the new workspace. - Split `web_run` session/page cache state so cached page reads use shared page handles and do not serialize through the mutation path. The harvest also adds panic-safe state write-back and serializes cache-mutating unit tests so @@ -54,7 +57,8 @@ Thanks to **@cyq1017** for the restore-listing implementation (#2513) and **@wywsoor** for the broader macOS/iTerm rollback UX report (#2494), and **@HUQIANTAO** for the `web_run` lock-splitting work (#2502) and turn-metadata prefix-cache stability work (#2517), **@xyuai** for canonical CodeWhale -settings-path migration work (#2730), and **@shenjackyuanjie** for the +settings-path migration work (#2730), **@gaord** for the runtime thread +workspace update API (#2640), and **@shenjackyuanjie** for the HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634). ## [0.8.53] - 2026-06-03 diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 6973a9c3..69e12e15 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -596,6 +596,7 @@ pub struct UpdateThreadRequest { pub mode: Option, pub title: Option, pub system_prompt: Option, + pub workspace: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1089,6 +1090,7 @@ impl RuntimeThreadManager { && req.mode.is_none() && req.title.is_none() && req.system_prompt.is_none() + && req.workspace.is_none() { bail!("At least one thread field is required"); } @@ -1103,6 +1105,11 @@ impl RuntimeThreadManager { { bail!("mode must not be empty"); } + if let Some(workspace) = req.workspace.as_ref() + && workspace.as_os_str().is_empty() + { + bail!("workspace must not be empty"); + } let mut thread = self.get_thread(id).await?; let mut changes = serde_json::Map::new(); @@ -1166,10 +1173,24 @@ impl RuntimeThreadManager { changes.insert("system_prompt".to_string(), json!(new_sys)); } } + if let Some(workspace) = req.workspace + && thread.workspace != workspace + { + changes.insert("workspace".to_string(), json!(workspace)); + thread.workspace = workspace; + } if !changes.is_empty() { + let workspace_changed = changes.contains_key("workspace"); + if workspace_changed { + self.ensure_thread_has_no_active_turn(&thread.id).await?; + } + thread.updated_at = Utc::now(); self.store.save_thread(&thread)?; + if workspace_changed { + self.evict_cached_engine(&thread.id).await; + } self.emit_event( &thread.id, None, @@ -1186,6 +1207,30 @@ impl RuntimeThreadManager { Ok(thread) } + async fn ensure_thread_has_no_active_turn(&self, thread_id: &str) -> Result<()> { + let active = self.active.lock().await; + if active + .engines + .get(thread_id) + .and_then(|state| state.active_turn.as_ref()) + .is_some() + { + bail!("workspace cannot be changed while the thread has an active turn"); + } + Ok(()) + } + + async fn evict_cached_engine(&self, thread_id: &str) { + let engine = { + let mut active = self.active.lock().await; + active.lru.retain(|id| id != thread_id); + active.engines.remove(thread_id).map(|state| state.engine) + }; + if let Some(engine) = engine { + let _ = engine.send(Op::Shutdown).await; + } + } + pub async fn get_thread_detail(&self, id: &str) -> Result { let thread = self.get_thread(id).await?; let turns = self.store.list_turns_for_thread(id)?; @@ -3777,6 +3822,169 @@ mod tests { Ok(()) } + #[tokio::test] + async fn update_thread_workspace_persists_event_and_evicts_idle_engine() -> Result<()> { + let manager = test_manager(test_runtime_dir())?; + let old_workspace = std::env::temp_dir().join("codewhale-runtime-old-workspace"); + let new_workspace = std::env::temp_dir().join("codewhale-runtime-new-workspace"); + let thread = manager + .create_thread(CreateThreadRequest { + model: None, + workspace: Some(old_workspace.clone()), + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: None, + archived: false, + system_prompt: None, + task_id: None, + }) + .await?; + + let harness = install_mock_engine(&manager, &thread.id).await; + let mut rx_op = harness.rx_op; + + let updated = manager + .update_thread( + &thread.id, + UpdateThreadRequest { + workspace: Some(new_workspace.clone()), + ..UpdateThreadRequest::default() + }, + ) + .await?; + + assert_eq!(updated.workspace, new_workspace); + assert_eq!( + manager.store.load_thread(&thread.id)?.workspace, + new_workspace + ); + { + let active = manager.active.lock().await; + assert!( + !active.engines.contains_key(&thread.id), + "workspace changes must evict the stale cached engine" + ); + assert!(!active.lru.iter().any(|id| id == &thread.id)); + } + + match tokio::time::timeout(Duration::from_secs(1), rx_op.recv()).await { + Ok(Some(Op::Shutdown)) => {} + other => panic!("expected cached engine shutdown, got {other:?}"), + } + + let events = manager.events_since(&thread.id, None)?; + let event = events + .iter() + .rev() + .find(|event| event.event == "thread.updated") + .expect("thread.updated event"); + let workspace_value = serde_json::to_value(&updated.workspace)?; + assert_eq!( + event + .payload + .get("changes") + .and_then(|changes| changes.get("workspace")), + Some(&workspace_value) + ); + Ok(()) + } + + #[tokio::test] + async fn update_thread_workspace_rejects_empty_path() -> Result<()> { + let manager = test_manager(test_runtime_dir())?; + let thread = manager + .create_thread(CreateThreadRequest { + model: None, + workspace: None, + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: None, + archived: false, + system_prompt: None, + task_id: None, + }) + .await?; + + let err = manager + .update_thread( + &thread.id, + UpdateThreadRequest { + workspace: Some(PathBuf::new()), + ..UpdateThreadRequest::default() + }, + ) + .await + .expect_err("empty workspace must be rejected"); + assert!(format!("{err:#}").contains("workspace must not be empty")); + Ok(()) + } + + #[tokio::test] + async fn update_thread_workspace_rejects_active_turn() -> Result<()> { + let manager = test_manager(test_runtime_dir())?; + let old_workspace = std::env::temp_dir().join("codewhale-runtime-active-old"); + let new_workspace = std::env::temp_dir().join("codewhale-runtime-active-new"); + let thread = manager + .create_thread(CreateThreadRequest { + model: None, + workspace: Some(old_workspace.clone()), + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: None, + archived: false, + system_prompt: None, + task_id: None, + }) + .await?; + + let harness = install_mock_engine(&manager, &thread.id).await; + let mut rx_op = harness.rx_op; + { + let mut active = manager.active.lock().await; + let state = active.engines.get_mut(&thread.id).expect("mock engine"); + state.active_turn = Some(ActiveTurnState { + turn_id: "turn_live".to_string(), + interrupt_requested: false, + auto_approve: false, + trust_mode: false, + }); + } + + let err = manager + .update_thread( + &thread.id, + UpdateThreadRequest { + workspace: Some(new_workspace), + ..UpdateThreadRequest::default() + }, + ) + .await + .expect_err("workspace update during active turn must fail"); + + assert!(format!("{err:#}").contains("active turn")); + assert_eq!( + manager.store.load_thread(&thread.id)?.workspace, + old_workspace + ); + { + let active = manager.active.lock().await; + assert!( + active.engines.contains_key(&thread.id), + "active engine should stay cached after rejected update" + ); + } + assert!( + tokio::time::timeout(Duration::from_millis(100), rx_op.recv()) + .await + .is_err(), + "rejected workspace update must not shut down the active engine" + ); + Ok(()) + } + #[tokio::test] async fn start_turn_passes_effective_auto_approve_to_engine() -> Result<()> { let manager = test_manager(test_runtime_dir())?; diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index d4cd7789..6c49a789 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -44,6 +44,7 @@ harvest/stewardship commits: | #2627 Xiaomi MiMo Token Plan mode | Harvested only the auth-header behavior as `5aa68d986`; did not merge the conflicting mode/env changes. | `cargo test -p codewhale-tui --bin codewhale-tui --locked xiaomi_mimo`; `cargo test -p codewhale-secrets --locked xiaomi_mimo`; `cargo test -p codewhale-config --locked xiaomi_mimo`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | | #2730 canonical codewhale settings path | Already harvested as `9e15805f6`; follow-up reviewer assertion added on this branch. | Fixes #2664 by reading legacy DeepSeek settings fallbacks, migrating them into `~/.codewhale/settings.toml`, and ensuring `/config` displays the canonical CodeWhale path. `cargo test -p codewhale-tui --bin codewhale-tui --locked settings_ -- --nocapture` passed. | | Contributor credit plumbing | Added locally after the co-author audit. | Normalized unpushed harvest author/trailer emails to numeric GitHub noreply identities, added `.github/AUTHOR_MAP`, and wired `scripts/check-coauthor-trailers.py` into CI so future `Harvested from PR #N by @handle` commits require machine-readable credit. | +| #2640 workspace field on UpdateThreadRequest | Harvested with the stale-engine fix restored. | Added `workspace` to `PATCH /v1/threads/{id}`, rejects empty paths, rejects workspace changes during active turns, and evicts idle cached engines so the next turn uses the new workspace. `cargo test -p codewhale-tui --bin codewhale-tui --locked update_thread_workspace -- --nocapture` and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | | #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. | | #2634 HarmonyOS port | Locally harvested with additional Nix-chain clearance; keep credited and do not close until the integration branch is public. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. Added env-driven OpenHarmony SDK setup, OHOS platform guards/fallbacks, self-update disablement, and OHOS target gating for Starlark execpolicy parsing plus PTY support so published OHOS builds do not pull `nix` 0.28 through `rustyline` or `portable-pty`. `cargo check --workspace --all-features --locked`, focused PTY/clipboard tests, and `cargo tree --locked -p codewhale-tui --target aarch64-unknown-linux-ohos -i nix@0.28.0` passed; full OHOS target check is blocked on this host because `OHOS_NATIVE_SDK`/target CC/sysroot are not configured and `ring` cannot find `assert.h`. | | #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. | @@ -106,7 +107,7 @@ harvest/stewardship commits: | #2635 output rows cache | Mergeable | Already harvested into the 22-commit stack. | | #2636 project-context cache | Conflicting | Defer/harvest only after cache correctness fixes. | | #2639 POST /v1/sessions endpoint | Mergeable | Defer; app-server contract needs focused review. | -| #2640 workspace field on UpdateThreadRequest | Mergeable | Defer; app-server contract needs focused review. | +| #2640 workspace field on UpdateThreadRequest | Mergeable | Harvested locally with extra tests and engine-cache invalidation. Comment/close original after integration branch is public, crediting @gaord. | | #2646 release publish hardening | Mergeable | Already harvested into the 22-commit stack. | | #2687 append-only mode/approval prompt | Draft/mergeable | Defer. Review found compile failures and Agent-mode prompt leakage into Plan sessions via hard-coded prompt refresh. | | #2708 Windows width fix | Mergeable | Cherry-picked and patched locally. |