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:
+5
-1
@@ -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
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user