Skip to content

Commit 897b00f

Browse files
cursoragentEXboys
andcommitted
Fix evolution CLI workspace DB scoping
Co-authored-by: EXboy <EXboys@users.noreply.github.com>
1 parent cce801b commit 897b00f

15 files changed

Lines changed: 591 additions & 31 deletions

File tree

crates/skilllite-assistant/src-tauri/src/skilllite_bridge/integrations/evolution_ui/backlog.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ pub fn get_evolution_proposal_status(
2828
"evolution",
2929
"proposal-status",
3030
"--json",
31+
"--workspace",
32+
workspace,
3133
proposal_id,
3234
],
3335
)

crates/skilllite-commands/src/evolution.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,17 +156,18 @@ fn query_backlog_rows(
156156
pub fn cmd_backlog(
157157
json: bool,
158158
hide_closed: bool,
159+
workspace: &str,
159160
status: Option<&str>,
160161
risk: Option<&str>,
161162
limit: usize,
162163
) -> Result<()> {
163164
if json && hide_closed {
164-
let rows = query_backlog_desktop(limit)?;
165+
let rows = query_backlog_desktop(workspace, limit)?;
165166
println!("{}", serde_json::to_string_pretty(&rows)?);
166167
return Ok(());
167168
}
168169

169-
let root = paths::chat_root();
170+
let root = crate::evolution_status::chat_root_for_workspace(workspace);
170171
let status_filter = normalize_status_filter(status)?;
171172
let risk_filter = normalize_risk_filter(risk)?;
172173
let rows = query_backlog_rows(
@@ -537,8 +538,8 @@ pub fn cmd_pending(json: bool, workspace: &str) -> Result<()> {
537538
}
538539

539540
/// `skilllite evolution proposal-status <proposal_id>` — single backlog row for desktop.
540-
pub fn cmd_proposal_status(json: bool, proposal_id: &str) -> Result<()> {
541-
let row = desktop_query_proposal_status(proposal_id)?;
541+
pub fn cmd_proposal_status(json: bool, workspace: &str, proposal_id: &str) -> Result<()> {
542+
let row = desktop_query_proposal_status(workspace, proposal_id)?;
542543
if json {
543544
println!("{}", serde_json::to_string_pretty(&row)?);
544545
} else {

crates/skilllite-commands/src/evolution_desktop.rs

Lines changed: 144 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use std::path::PathBuf;
55
use serde::{Deserialize, Serialize};
66
use skilllite_core::skill::discovery::resolve_skills_dir_with_legacy_fallback;
77

8-
use crate::evolution_status::resolve_workspace_root;
8+
use crate::evolution_status::{chat_root_for_workspace, resolve_workspace_root};
99
use crate::Result;
1010

1111
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -68,8 +68,11 @@ fn truncate_utf8(s: &str, max: usize) -> String {
6868
format!("{}…", &s[..end])
6969
}
7070

71-
pub fn query_backlog_desktop(limit: usize) -> Result<Vec<EvolutionBacklogRowSnapshot>> {
72-
let chat_root = skilllite_core::paths::chat_root();
71+
pub fn query_backlog_desktop(
72+
workspace: &str,
73+
limit: usize,
74+
) -> Result<Vec<EvolutionBacklogRowSnapshot>> {
75+
let chat_root = chat_root_for_workspace(workspace);
7376
let conn = skilllite_evolution::feedback::open_evolution_db(&chat_root)?;
7477
let limit = limit.clamp(1, 200);
7578
let mut stmt = conn
@@ -102,8 +105,11 @@ pub fn query_backlog_desktop(limit: usize) -> Result<Vec<EvolutionBacklogRowSnap
102105
.collect::<Result<Vec<_>>>()
103106
}
104107

105-
pub fn query_proposal_status(proposal_id: &str) -> Result<EvolutionProposalStatusSnapshot> {
106-
let chat_root = skilllite_core::paths::chat_root();
108+
pub fn query_proposal_status(
109+
workspace: &str,
110+
proposal_id: &str,
111+
) -> Result<EvolutionProposalStatusSnapshot> {
112+
let chat_root = chat_root_for_workspace(workspace);
107113
let conn = skilllite_evolution::feedback::open_evolution_db(&chat_root)?;
108114
conn.query_row(
109115
"SELECT proposal_id, status, acceptance_status, updated_at, note
@@ -167,7 +173,7 @@ pub fn read_pending_skill_md(workspace: &str, skill_name: &str) -> Result<String
167173
pub fn confirm_pending_skill(workspace: &str, skill_name: &str) -> Result<()> {
168174
let skills_root = resolve_skills_root(workspace)?;
169175
skilllite_evolution::skill_synth::confirm_pending_skill(&skills_root, skill_name)?;
170-
let chat_root = skilllite_core::paths::chat_root();
176+
let chat_root = chat_root_for_workspace(workspace);
171177
if let Ok(conn) = skilllite_evolution::feedback::open_evolution_db(&chat_root) {
172178
let _ = skilllite_evolution::log_evolution_event(
173179
&conn,
@@ -198,8 +204,8 @@ pub fn authorize_capability_evolution(
198204
outcome: &str,
199205
summary: &str,
200206
) -> Result<AuthorizeCapabilitySnapshot> {
201-
let _ = resolve_workspace_root(workspace);
202-
let chat_root = skilllite_core::paths::chat_root();
207+
let workspace_root = resolve_workspace_root(workspace);
208+
let chat_root = workspace_root.join("chat");
203209
let conn = skilllite_evolution::feedback::open_evolution_db(&chat_root)?;
204210
let proposal_id =
205211
skilllite_evolution::enqueue_user_capability_evolution(&conn, tool_name, outcome, summary)?;
@@ -223,7 +229,7 @@ pub fn log_manual_evolution_trigger(
223229
proposal_id: Option<&str>,
224230
summary: &str,
225231
) -> Result<()> {
226-
let chat_root = skilllite_core::paths::chat_root();
232+
let chat_root = chat_root_for_workspace(workspace);
227233
let conn = skilllite_evolution::feedback::open_evolution_db(&chat_root)?;
228234
let clipped = clip_manual_trigger_summary(summary);
229235
let _ = skilllite_evolution::log_evolution_event(
@@ -240,6 +246,65 @@ pub fn log_manual_evolution_trigger(
240246
#[cfg(test)]
241247
mod tests {
242248
use super::*;
249+
use skilllite_core::config::env_keys::paths as env_paths;
250+
use std::sync::Mutex;
251+
252+
static ENV_LOCK: Mutex<()> = Mutex::new(());
253+
254+
struct EnvRestore {
255+
key: &'static str,
256+
previous: Option<String>,
257+
}
258+
259+
impl EnvRestore {
260+
fn set(key: &'static str, value: &str) -> Self {
261+
let previous = std::env::var(key).ok();
262+
skilllite_core::config::set_env_var(key, value);
263+
Self { key, previous }
264+
}
265+
}
266+
267+
impl Drop for EnvRestore {
268+
fn drop(&mut self) {
269+
if let Some(value) = &self.previous {
270+
skilllite_core::config::set_env_var(self.key, value);
271+
} else {
272+
skilllite_core::config::remove_env_var(self.key);
273+
}
274+
}
275+
}
276+
277+
fn temp_workspace(label: &str) -> PathBuf {
278+
std::env::temp_dir().join(format!(
279+
"skilllite-evo-desktop-{label}-{}",
280+
uuid::Uuid::new_v4()
281+
))
282+
}
283+
284+
fn seed_backlog_row(workspace: &std::path::Path, proposal_id: &str, note: &str) {
285+
let chat_root = workspace.join("chat");
286+
let conn = skilllite_evolution::feedback::open_evolution_db(&chat_root).expect("open db");
287+
let dedupe_key = format!("dedupe_{proposal_id}");
288+
conn.execute(
289+
"INSERT INTO evolution_backlog
290+
(proposal_id, source, dedupe_key, scope_json, risk_level, roi_score, expected_gain, effort, acceptance_criteria, status, acceptance_status, note)
291+
VALUES (?1, 'active', ?2, '{}', 'low', 0.5, 0.5, 1.0, '[]', 'queued', 'pending_validation', ?3)",
292+
[proposal_id, dedupe_key.as_str(), note],
293+
)
294+
.expect("insert backlog row");
295+
}
296+
297+
fn capability_rows(workspace: &std::path::Path, tool_name: &str) -> i64 {
298+
let chat_root = workspace.join("chat");
299+
let conn = skilllite_evolution::feedback::open_evolution_db(&chat_root).expect("open db");
300+
let dedupe_key = format!("user_capability:{tool_name}:failure");
301+
conn.query_row(
302+
"SELECT COUNT(*) FROM evolution_backlog WHERE dedupe_key = ?1",
303+
[dedupe_key.as_str()],
304+
|row| row.get(0),
305+
)
306+
.expect("count rows")
307+
}
243308

244309
#[test]
245310
fn manual_trigger_summary_clip_is_utf8_boundary_safe() {
@@ -260,4 +325,74 @@ mod tests {
260325

261326
assert_eq!(clip_manual_trigger_summary(summary), summary);
262327
}
328+
329+
#[test]
330+
fn query_backlog_desktop_uses_workspace_argument_over_env() {
331+
let _lock = ENV_LOCK.lock().expect("env lock");
332+
let env_workspace = temp_workspace("env");
333+
let target_workspace = temp_workspace("target");
334+
let _env_restore = EnvRestore::set(
335+
env_paths::SKILLLITE_WORKSPACE,
336+
env_workspace.to_string_lossy().as_ref(),
337+
);
338+
seed_backlog_row(&env_workspace, "env_only", "ENV_DB_ROW");
339+
seed_backlog_row(&target_workspace, "target_only", "TARGET_DB_ROW");
340+
341+
let rows =
342+
query_backlog_desktop(target_workspace.to_string_lossy().as_ref(), 10).expect("query");
343+
344+
let ids: Vec<_> = rows.into_iter().map(|row| row.proposal_id).collect();
345+
assert_eq!(ids, vec!["target_only"]);
346+
let _ = std::fs::remove_dir_all(env_workspace);
347+
let _ = std::fs::remove_dir_all(target_workspace);
348+
}
349+
350+
#[test]
351+
fn query_proposal_status_uses_workspace_argument_over_env() {
352+
let _lock = ENV_LOCK.lock().expect("env lock");
353+
let env_workspace = temp_workspace("env");
354+
let target_workspace = temp_workspace("target");
355+
let _env_restore = EnvRestore::set(
356+
env_paths::SKILLLITE_WORKSPACE,
357+
env_workspace.to_string_lossy().as_ref(),
358+
);
359+
seed_backlog_row(&env_workspace, "shared_id", "ENV_DB_ROW");
360+
seed_backlog_row(&target_workspace, "shared_id", "TARGET_DB_ROW");
361+
362+
let row = query_proposal_status(target_workspace.to_string_lossy().as_ref(), "shared_id")
363+
.expect("query");
364+
365+
assert_eq!(row.note.as_deref(), Some("TARGET_DB_ROW"));
366+
let _ = std::fs::remove_dir_all(env_workspace);
367+
let _ = std::fs::remove_dir_all(target_workspace);
368+
}
369+
370+
#[test]
371+
fn authorize_capability_uses_workspace_argument_over_env() {
372+
let _lock = ENV_LOCK.lock().expect("env lock");
373+
let env_workspace = temp_workspace("env");
374+
let target_workspace = temp_workspace("target");
375+
let tool_name = "workspace_scope_regression";
376+
let _env_restore = EnvRestore::set(
377+
env_paths::SKILLLITE_WORKSPACE,
378+
env_workspace.to_string_lossy().as_ref(),
379+
);
380+
let _ = skilllite_evolution::feedback::open_evolution_db(&env_workspace.join("chat"))
381+
.expect("open env db");
382+
let _ = skilllite_evolution::feedback::open_evolution_db(&target_workspace.join("chat"))
383+
.expect("open target db");
384+
385+
authorize_capability_evolution(
386+
target_workspace.to_string_lossy().as_ref(),
387+
tool_name,
388+
"failure",
389+
"summary",
390+
)
391+
.expect("authorize");
392+
393+
assert_eq!(capability_rows(&env_workspace, tool_name), 0);
394+
assert_eq!(capability_rows(&target_workspace, tool_name), 1);
395+
let _ = std::fs::remove_dir_all(env_workspace);
396+
let _ = std::fs::remove_dir_all(target_workspace);
397+
}
263398
}

crates/skilllite-commands/src/evolution_status.rs

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ pub(crate) fn resolve_workspace_root(workspace: &str) -> PathBuf {
7575
}
7676
}
7777

78+
pub(crate) fn chat_root_for_workspace(workspace: &str) -> PathBuf {
79+
resolve_workspace_root(workspace).join("chat")
80+
}
81+
7882
fn workspace_env_lookup(workspace_root: &Path, key: &str) -> Option<String> {
7983
skilllite_core::config::parse_dotenv_from_dir(workspace_root)
8084
.into_iter()
@@ -156,7 +160,7 @@ pub fn build_evolution_status_snapshot(params: &EvolutionStatusParams) -> Evolut
156160
skilllite_evolution::skill_synth::list_pending_skills_with_review(&skills_root).len();
157161
}
158162

159-
let chat_root = skilllite_core::paths::chat_root();
163+
let chat_root = workspace_root.join("chat");
160164
let mut db_error = None;
161165
let mut unprocessed_decisions = 0i64;
162166
let mut weighted_signal_sum = 0i64;
@@ -298,7 +302,7 @@ pub fn cmd_status(json: bool, workspace: &str, periodic_anchor_unix: Option<i64>
298302
fn cmd_status_human(workspace: &str) -> Result<()> {
299303
let workspace_root = resolve_workspace_root(workspace);
300304
skilllite_core::config::load_dotenv_from_dir(&workspace_root);
301-
let root = skilllite_core::paths::chat_root();
305+
let root = workspace_root.join("chat");
302306
let conn = skilllite_evolution::feedback::open_evolution_db(&root)?;
303307
let mode = evolution_mode_from_workspace(&workspace_root);
304308

@@ -420,6 +424,81 @@ fn cmd_status_human(workspace: &str) -> Result<()> {
420424
Ok(())
421425
}
422426

427+
#[cfg(test)]
428+
mod workspace_scope_tests {
429+
use super::*;
430+
use skilllite_core::config::env_keys::paths as env_paths;
431+
use std::sync::Mutex;
432+
433+
static ENV_LOCK: Mutex<()> = Mutex::new(());
434+
435+
struct EnvRestore {
436+
key: &'static str,
437+
previous: Option<String>,
438+
}
439+
440+
impl EnvRestore {
441+
fn set(key: &'static str, value: &str) -> Self {
442+
let previous = std::env::var(key).ok();
443+
skilllite_core::config::set_env_var(key, value);
444+
Self { key, previous }
445+
}
446+
}
447+
448+
impl Drop for EnvRestore {
449+
fn drop(&mut self) {
450+
if let Some(value) = &self.previous {
451+
skilllite_core::config::set_env_var(self.key, value);
452+
} else {
453+
skilllite_core::config::remove_env_var(self.key);
454+
}
455+
}
456+
}
457+
458+
fn temp_workspace(label: &str) -> PathBuf {
459+
std::env::temp_dir().join(format!(
460+
"skilllite-evo-status-{label}-{}",
461+
uuid::Uuid::new_v4()
462+
))
463+
}
464+
465+
fn seed_decision(workspace: &Path) {
466+
let conn = skilllite_evolution::feedback::open_evolution_db(&workspace.join("chat"))
467+
.expect("open db");
468+
conn.execute(
469+
"INSERT INTO decisions
470+
(evolved, total_tools, failed_tools, replans, task_completed, task_description, ts)
471+
VALUES (0, 1, 0, 0, 1, 'workspace scoped decision', datetime('now'))",
472+
[],
473+
)
474+
.expect("insert decision");
475+
}
476+
477+
#[test]
478+
fn status_snapshot_uses_workspace_argument_for_db_over_env() {
479+
let _lock = ENV_LOCK.lock().expect("env lock");
480+
let env_workspace = temp_workspace("env");
481+
let target_workspace = temp_workspace("target");
482+
let _env_restore = EnvRestore::set(
483+
env_paths::SKILLLITE_WORKSPACE,
484+
env_workspace.to_string_lossy().as_ref(),
485+
);
486+
let _ = skilllite_evolution::feedback::open_evolution_db(&env_workspace.join("chat"))
487+
.expect("open env db");
488+
seed_decision(&target_workspace);
489+
490+
let snapshot = build_evolution_status_snapshot(&EvolutionStatusParams {
491+
workspace: target_workspace.to_string_lossy().to_string(),
492+
periodic_anchor_unix: None,
493+
});
494+
495+
assert_eq!(snapshot.unprocessed_decisions, 1);
496+
assert!(snapshot.db_error.is_none());
497+
let _ = std::fs::remove_dir_all(env_workspace);
498+
let _ = std::fs::remove_dir_all(target_workspace);
499+
}
500+
}
501+
423502
#[cfg(test)]
424503
mod tests {
425504
use super::*;

docs/en/ASSISTANT-SPLIT-ARCHITECTURE.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,16 +121,16 @@ Priority commands for parity with today’s Desktop bridge:
121121
| Command | JSON output | Notes |
122122
|---------|-------------|-------|
123123
| `skilllite evolution status --json` | `EvolutionStatusSnapshot` | **Shipped**; `--workspace`, `--periodic-anchor-unix` |
124-
| `skilllite evolution backlog --json --hide-closed` | `EvolutionBacklogRowSnapshot[]` | **Shipped** (desktop filter) |
125-
| `skilllite evolution pending --json` | `PendingSkillSnapshot[]` | **Shipped** |
126-
| `skilllite evolution proposal-status --json <id>` | `EvolutionProposalStatusSnapshot` | **Shipped** |
127-
| `skilllite evolution confirm/reject --json` | `EvolutionOpSnapshot` | **Shipped** |
124+
| `skilllite evolution backlog --json --hide-closed` | `EvolutionBacklogRowSnapshot[]` | **Shipped**; `--workspace` (desktop filter) |
125+
| `skilllite evolution pending --json` | `PendingSkillSnapshot[]` | **Shipped**; `--workspace` |
126+
| `skilllite evolution proposal-status --json <id>` | `EvolutionProposalStatusSnapshot` | **Shipped**; `--workspace` |
127+
| `skilllite evolution confirm/reject --json` | `EvolutionOpSnapshot` | **Shipped**; `--workspace` |
128128
| `skilllite evolution run --json` | `NodeResult` | **Shipped**; `--workspace`, `--proposal-id`, `--log-manual-trigger` |
129129
| `skilllite runtime probe --json` | `RuntimeUiSnapshot` | **Shipped** |
130130
| `skilllite runtime provision --json` | stderr progress JSON lines + `ProvisionRuntimesResult` on stdout | **Shipped**; `--python` / `--node` / `--force` |
131131
| `skilllite skills list --json --workspace` | `DesktopSkillSnapshot[]` (desktop `DesktopSkillInfo`) | **Shipped** |
132132
| `skilllite suggest-followup --json` | `{ "suggestions": string[] }` | **Shipped** |
133-
| `skilllite evolution authorize-capability --json` | `{ "proposal_id": string }` | **Shipped** |
133+
| `skilllite evolution authorize-capability --json` | `{ "proposal_id": string }` | **Shipped**; `--workspace` |
134134

135135
**Convention:** `--json` always prints a single JSON document on stdout; human text on stderr only.
136136

0 commit comments

Comments
 (0)