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>
This commit is contained in:
Hunter B
2026-06-03 21:11:55 -07:00
parent 002f8f0ba1
commit 66c88ddfae
3 changed files with 215 additions and 2 deletions
+5 -1
View File
@@ -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
+208
View File
@@ -596,6 +596,7 @@ pub struct UpdateThreadRequest {
pub mode: Option<String>,
pub title: Option<String>,
pub system_prompt: Option<String>,
pub workspace: Option<PathBuf>,
}
#[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<ThreadDetail> {
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())?;
+2 -1
View File
@@ -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. |