Skip to content

Commit 1bbbce7

Browse files
committed
fix: close dashboard-backend findings from Oracle audit wave 5 (1 P0 + 5 P1/P2)
Three parallel read-only Oracles (Pi context-handler internals; dashboard Rust backend; m[0]/m[1] cache-core). HEADLINE: the cache-core is verified SOUND — no P0 history-loss or cache-hit replay leak (DEFER replay byte-stable, supersede- delta + CAS correct, atomic paired persistence). The cache-core + Pi findings are cache/migration-critical → banked for review (cache-stability-path rule). The dashboard-Rust batch is fixed here. P0: - Project-identity fallback cache divergence (project_identity.rs). Rust cached a dir:<md5> fallback for a non-git dir FOREVER in-process; after the dir gets .git + first commit, TS re-resolves to git:<root> but Rust kept serving the stale dir: — so dashboard filters/writes could target the WRONG project's memories. Fixed: serve cached git: directly, but drop a cached dir: fallback the moment has_git_dir() is true and re-resolve (mirrors TS resolveProjectIdentity). Also: classify the pre-spawn dir check via fs::metadata so NotFound stays deterministic (cacheable) but PermissionDenied/other are transient (uncached). +regression test (no-git → git init+commit flips dir:→git: without cache clear). P1: - update_memory_status accepted arbitrary status strings (db.rs) — a malformed "archive" (vs "archived") would make a memory vanish from active/permanent/ archived logic. Now reject anything outside the canonical set with a friendly error before touching the DB. - Dream-queue dedup not atomic (db.rs — a regression in my own wave-3 enqueue dedup). dream_queue has no UNIQUE on project_path, so a DEFERRED check-then- insert let two concurrent "dream now" clicks both insert. enqueue_dream + delete_dream_queue_entry now take &mut Connection and run under BEGIN IMMEDIATE. - Session-detail used p.worktree instead of s.directory (db.rs get_opencode_session_detail) — global-bucketed git sessions (worktree "/") mis-resolved identity and showed "/". Now derive identity/display/path from s.directory (worktree fallback), matching list_opencode_sessions. P2: - open_readwrite could CREATE an empty DB if the file vanished (db.rs) — violates the "dashboard never owns schema" boundary. Now open_with_flags(READ_WRITE | NO_MUTEX) without CREATE. Banked (need review): cache-core taxonomy refinements (O: /ctx-flush SOFT violation, Pi eager-clear, hard-fold expiry determinism), Pi v25 tool-owner cutover P0 (M), dashboard workspace share-category viewer filter (N-P1, product semantics). See .alfonso/oracle-loop-findings.md. Gate: dashboard rust 93+21+11+20/0, cargo check + fmt clean.
1 parent 9de0699 commit 1bbbce7

4 files changed

Lines changed: 207 additions & 28 deletions

File tree

packages/dashboard/src-tauri/src/commands.rs

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -465,15 +465,15 @@ pub fn enqueue_dream(
465465
reason: String,
466466
) -> Result<i64, String> {
467467
let path = state.get_db_path()?;
468-
let conn = db::open_readwrite(&path).map_err(|e| e.to_string())?;
469-
db::enqueue_dream(&conn, &project_path, &reason).map_err(|e| e.to_string())
468+
let mut conn = db::open_readwrite(&path).map_err(|e| e.to_string())?;
469+
db::enqueue_dream(&mut conn, &project_path, &reason).map_err(|e| e.to_string())
470470
}
471471

472472
#[tauri::command(async)]
473473
pub fn delete_dream_queue_entry(state: State<'_, AppState>, id: i64) -> Result<usize, String> {
474474
let path = state.get_db_path()?;
475-
let conn = db::open_readwrite(&path).map_err(|e| e.to_string())?;
476-
db::delete_dream_queue_entry(&conn, id).map_err(|e| e.to_string())
475+
let mut conn = db::open_readwrite(&path).map_err(|e| e.to_string())?;
476+
db::delete_dream_queue_entry(&mut conn, id).map_err(|e| e.to_string())
477477
}
478478

479479
// ── Log commands ────────────────────────────────────────────
@@ -683,7 +683,10 @@ pub async fn get_available_models() -> Vec<String> {
683683
list.push(format!("{}\\npm\\opencode.exe", appdata));
684684
}
685685
if !localappdata.is_empty() {
686-
list.push(format!("{}\\Microsoft\\WinGet\\Links\\opencode.exe", localappdata));
686+
list.push(format!(
687+
"{}\\Microsoft\\WinGet\\Links\\opencode.exe",
688+
localappdata
689+
));
687690
}
688691
if !userprofile.is_empty() {
689692
list.push(format!("{}\\scoop\\shims\\opencode.exe", userprofile));
@@ -777,7 +780,10 @@ pub fn parse_pi_models_output(text: &str) -> Vec<String> {
777780
for raw_line in strip_ansi_pi_output(text).lines() {
778781
let mut line = raw_line.trim().to_string();
779782
if line.starts_with('•') || line.starts_with('*') || line.starts_with('-') {
780-
line = line.trim_start_matches(['•', '*', '-']).trim_start().to_string();
783+
line = line
784+
.trim_start_matches(['•', '*', '-'])
785+
.trim_start()
786+
.to_string();
781787
}
782788
if line.is_empty() || line.to_ascii_lowercase().contains("usage:") {
783789
continue;
@@ -825,7 +831,10 @@ pub async fn get_available_pi_models() -> Vec<String> {
825831
list.push(format!("{}\\npm\\pi.exe", appdata));
826832
}
827833
if !localappdata.is_empty() {
828-
list.push(format!("{}\\Microsoft\\WinGet\\Links\\pi.exe", localappdata));
834+
list.push(format!(
835+
"{}\\Microsoft\\WinGet\\Links\\pi.exe",
836+
localappdata
837+
));
829838
}
830839
if !userprofile.is_empty() {
831840
list.push(format!("{}\\scoop\\shims\\pi.exe", userprofile));
@@ -1128,7 +1137,13 @@ mod tests {
11281137
fn test_pick_first_line() {
11291138
assert_eq!(pick_first_line(""), None);
11301139
assert_eq!(pick_first_line(" \n"), None);
1131-
assert_eq!(pick_first_line("C:\\bin\\opencode.exe\nC:\\other\\opencode.exe"), Some("C:\\bin\\opencode.exe".to_string()));
1132-
assert_eq!(pick_first_line(" C:\\bin\\opencode.exe \n"), Some("C:\\bin\\opencode.exe".to_string()));
1140+
assert_eq!(
1141+
pick_first_line("C:\\bin\\opencode.exe\nC:\\other\\opencode.exe"),
1142+
Some("C:\\bin\\opencode.exe".to_string())
1143+
);
1144+
assert_eq!(
1145+
pick_first_line(" C:\\bin\\opencode.exe \n"),
1146+
Some("C:\\bin\\opencode.exe".to_string())
1147+
);
11331148
}
11341149
}

packages/dashboard/src-tauri/src/db.rs

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,14 @@ pub fn open_readonly(path: &PathBuf) -> Result<Connection, rusqlite::Error> {
120120

121121
/// Opens a read-write connection for write operations (memory edits, queue entries).
122122
pub fn open_readwrite(path: &PathBuf) -> Result<Connection, rusqlite::Error> {
123-
let conn = Connection::open(path)?;
123+
// READ_WRITE WITHOUT CREATE: if the DB file vanished after startup,
124+
// Connection::open would CREATE an empty SQLite file — violating the
125+
// "dashboard never owns the schema" boundary. The plugin owns DB lifecycle;
126+
// a missing file should error, not silently spawn a blank DB.
127+
let conn = Connection::open_with_flags(
128+
path,
129+
rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
130+
)?;
124131
// busy_timeout MUST come before journal_mode=WAL: setting WAL can itself need
125132
// the file lock, and with the timeout installed last a cold-open under
126133
// contention fails immediately with SQLITE_BUSY instead of waiting.
@@ -2728,6 +2735,18 @@ pub fn update_memory_status(
27282735
memory_id: i64,
27292736
new_status: &str,
27302737
) -> Result<(), rusqlite::Error> {
2738+
// Reject any status outside the canonical set before touching the DB. A
2739+
// malformed call setting status="archive" (vs "archived") or any free string
2740+
// would make the memory vanish from active/permanent/archived logic with no
2741+
// valid epoch/delta interpretation.
2742+
if !matches!(new_status, "active" | "permanent" | "archived") {
2743+
return Err(rusqlite::Error::SqliteFailure(
2744+
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
2745+
Some(format!(
2746+
"Invalid memory status '{new_status}' (expected active, permanent, or archived)."
2747+
)),
2748+
));
2749+
}
27312750
// Phase A: resolve the target row before opening a write transaction.
27322751
let target = lookup_memory_mutation_target(conn, memory_id)?;
27332752
// Any transition that CHANGES which memories enter or how they rank in the
@@ -2893,7 +2912,12 @@ pub fn update_memory_category(
28932912
"SELECT id FROM memories
28942913
WHERE project_path = ?1 AND category = ?2 AND normalized_hash = ?3 AND id != ?4
28952914
LIMIT 1",
2896-
params![target.project_path, new_category, normalized_hash, memory_id],
2915+
params![
2916+
target.project_path,
2917+
new_category,
2918+
normalized_hash,
2919+
memory_id
2920+
],
28972921
|row| row.get(0),
28982922
)
28992923
.optional()?;
@@ -3198,7 +3222,11 @@ pub fn list_opencode_sessions(filter: &SessionFilter) -> Vec<SessionRow> {
31983222
let last_activity_ms: i64 = row.get(5)?;
31993223
// Prefer the session's real directory; fall back to the project worktree
32003224
// only when the session row has no directory (legacy rows).
3201-
let effective_dir = if directory.is_empty() { &worktree } else { &directory };
3225+
let effective_dir = if directory.is_empty() {
3226+
&worktree
3227+
} else {
3228+
&directory
3229+
};
32023230
let identity = resolve_project_identity(effective_dir);
32033231
let is_subagent = subagent_map.get(&session_id).copied().unwrap_or(false);
32043232
// Friendly label: the named project wins; otherwise the directory's
@@ -3459,6 +3487,7 @@ pub fn get_opencode_session_detail(
34593487
let oc_conn = open_readonly(&opencode_db_path)?;
34603488
let row = oc_conn.query_row(
34613489
"SELECT s.id, COALESCE(s.title, ''), COALESCE(p.name, ''), COALESCE(p.worktree, ''),
3490+
COALESCE(s.directory, ''),
34623491
COALESCE(json_object('id', s.id, 'title', s.title, 'directory', s.directory), '{}')
34633492
FROM session s LEFT JOIN project p ON p.id = s.project_id WHERE s.id = ?1",
34643493
[session_id],
@@ -3469,12 +3498,22 @@ pub fn get_opencode_session_detail(
34693498
row.get::<_, String>(2)?,
34703499
row.get::<_, String>(3)?,
34713500
row.get::<_, String>(4)?,
3501+
row.get::<_, String>(5)?,
34723502
))
34733503
},
34743504
);
3475-
let Ok((session_id, title, project_name, worktree, data_json)) = row else {
3505+
let Ok((session_id, title, project_name, worktree, directory, data_json)) = row else {
34763506
return Ok(None);
34773507
};
3508+
// Prefer s.directory over p.worktree for identity/display: OpenCode buckets
3509+
// git-repo sessions that had no remote/commit at creation under the `global`
3510+
// project (worktree "/"), and the plugin keys identity off session.directory —
3511+
// using worktree here would mis-resolve and show "/". Worktree is the fallback.
3512+
let effective_dir = if directory.is_empty() {
3513+
worktree.clone()
3514+
} else {
3515+
directory
3516+
};
34783517

34793518
// Cheap row counts for badge rendering. Both are O(rows-in-session) at
34803519
// worst but use only INTEGER aggregates (no JSON extraction or part-table
@@ -3522,13 +3561,13 @@ pub fn get_opencode_session_detail(
35223561
harness: Harness::Opencode,
35233562
session_id,
35243563
title,
3525-
project_identity: resolve_project_identity(&worktree),
3564+
project_identity: resolve_project_identity(&effective_dir),
35263565
project_display: if project_name.is_empty() {
3527-
basename(&worktree)
3566+
basename(&effective_dir)
35283567
} else {
35293568
project_name
35303569
},
3531-
project_path: (!worktree.is_empty()).then_some(worktree),
3570+
project_path: (!effective_dir.is_empty()).then_some(effective_dir),
35323571
opencode_session_json: serde_json::from_str(&data_json).ok(),
35333572
pi_jsonl_path: None,
35343573
messages_count,
@@ -4377,7 +4416,7 @@ pub fn get_dream_runs(
43774416
}
43784417

43794418
pub fn enqueue_dream(
4380-
conn: &Connection,
4419+
conn: &mut Connection,
43814420
project_path: &str,
43824421
reason: &str,
43834422
) -> Result<i64, rusqlite::Error> {
@@ -4386,35 +4425,46 @@ pub fn enqueue_dream(
43864425
// clicks pile up duplicate rows that a single identity-filtered host drains one
43874426
// at a time. project_path is the resolved identity (the UI passes git:/dir:),
43884427
// matching how hosts dequeue — a raw path would never be drained.
4428+
//
4429+
// The check + insert run under BEGIN IMMEDIATE: dream_queue has no UNIQUE on
4430+
// project_path, so a DEFERRED check-then-insert lets two concurrent clicks both
4431+
// pass the SELECT and insert duplicates. The writer lock serializes them.
43894432
let identity = normalize_stored_project_path(project_path);
4390-
let existing: Option<i64> = conn
4433+
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
4434+
let existing: Option<i64> = tx
43914435
.query_row(
43924436
"SELECT id FROM dream_queue WHERE project_path = ?1 LIMIT 1",
43934437
rusqlite::params![identity],
43944438
|row| row.get(0),
43954439
)
43964440
.optional()?;
43974441
if let Some(id) = existing {
4442+
tx.commit()?;
43984443
return Ok(id);
43994444
}
44004445
let now = chrono::Utc::now().timestamp_millis();
4401-
conn.execute(
4446+
tx.execute(
44024447
"INSERT INTO dream_queue (project_path, reason, enqueued_at) VALUES (?1, ?2, ?3)",
44034448
rusqlite::params![identity, reason, now],
44044449
)?;
4405-
Ok(conn.last_insert_rowid())
4450+
let id = tx.last_insert_rowid();
4451+
tx.commit()?;
4452+
Ok(id)
44064453
}
44074454

44084455
/// Delete a single dream-queue entry by id. Used to clear stale entries for
44094456
/// projects with no active runner (e.g. a manual dashboard trigger for a project
44104457
/// that is not currently loaded by any OpenCode/Pi host, so nothing ever
44114458
/// dequeues it). Returns the number of rows removed (0 if the id was already
44124459
/// gone — e.g. a runner picked it up between the list read and this call).
4413-
pub fn delete_dream_queue_entry(conn: &Connection, id: i64) -> Result<usize, rusqlite::Error> {
4414-
conn.execute(
4460+
pub fn delete_dream_queue_entry(conn: &mut Connection, id: i64) -> Result<usize, rusqlite::Error> {
4461+
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
4462+
let removed = tx.execute(
44154463
"DELETE FROM dream_queue WHERE id = ?1",
44164464
rusqlite::params![id],
4417-
)
4465+
)?;
4466+
tx.commit()?;
4467+
Ok(removed)
44184468
}
44194469

44204470
// ── User Memory types ───────────────────────────────────────
@@ -6052,7 +6102,10 @@ mod memory_project_filter_tests {
60526102

60536103
let rows = enumerate_memory_projects(&conn).expect("enumerate");
60546104
assert_eq!(rows.len(), 2);
6055-
assert_eq!(rows[0].identity, "dir:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321");
6105+
assert_eq!(
6106+
rows[0].identity,
6107+
"dir:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321"
6108+
);
60566109
assert_eq!(rows[0].display_name, "dir:fedcba0987…");
60576110
assert_eq!(rows[1].identity, "git:abc1234567890abcdef");
60586111
assert_eq!(rows[1].display_name, "git:abc1234567…");

packages/dashboard/src-tauri/src/project_identity.rs

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ fn cache() -> &'static RwLock<HashMap<PathBuf, String>> {
1616
IDENTITY_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
1717
}
1818

19+
/// Whether a `.git` exists at `canonical` (worktree dir or a gitdir file/dir).
20+
/// Used to invalidate a cached `dir:` fallback once a repo appears — mirrors the
21+
/// TS resolver's `hasGitDir` re-resolve gate.
22+
fn has_git_dir(canonical: &Path) -> bool {
23+
canonical.join(".git").exists()
24+
}
25+
1926
/// Lexically resolve `input` against `cwd`, matching Node's `path.resolve` semantics.
2027
///
2128
/// This intentionally does not touch the filesystem: no symlink resolution, no
@@ -98,9 +105,20 @@ pub fn resolve_project_identity_strict(directory: &Path) -> Result<String, Ident
98105
let canonical = logical_absolute(directory, &cwd);
99106

100107
// If the cwd itself is missing, the git spawn would also return NotFound; distinguish
101-
// that from a missing git binary before classifying the spawn error.
102-
if !canonical.exists() {
103-
return Err(IdentityErrorClass::PathInaccessible);
108+
// that from a missing git binary before classifying the spawn error. Use
109+
// metadata() (not exists(), which collapses ALL errors to false): a genuine
110+
// NotFound is DETERMINISTIC (cacheable dir: fallback), but a PermissionDenied/
111+
// other stat error is TRANSIENT and must NOT be cached (a retry could resolve
112+
// the real git: identity once access is restored).
113+
match std::fs::metadata(&canonical) {
114+
Ok(_) => {}
115+
Err(error) => {
116+
return Err(match error.kind() {
117+
std::io::ErrorKind::NotFound => IdentityErrorClass::PathInaccessible,
118+
std::io::ErrorKind::PermissionDenied => IdentityErrorClass::PermissionDenied,
119+
_ => IdentityErrorClass::Unknown,
120+
});
121+
}
104122
}
105123

106124
let mut child = Command::new("git")
@@ -168,7 +186,24 @@ pub fn resolve_project_identity<P: AsRef<Path>>(directory: P) -> String {
168186

169187
if let Ok(cache) = cache().read() {
170188
if let Some(identity) = cache.get(&canonical) {
171-
return identity.clone();
189+
// Serve a cached `git:` identity directly (stable once a repo exists).
190+
// A cached `dir:` FALLBACK, however, must be dropped the moment a `.git`
191+
// appears, so the identity can flip to the stable `git:<root>` — the
192+
// common "scratch dir later `git init` + first commit" case. Without
193+
// this re-resolve gate the dashboard pins the wrong `dir:` identity for
194+
// the whole process and mis-groups the project (P0: it then reads/
195+
// mutates the wrong project's memories). Mirrors TS resolveProjectIdentity.
196+
if identity.starts_with("git:") || !has_git_dir(&canonical) {
197+
return identity.clone();
198+
}
199+
}
200+
}
201+
// Cached fallback is stale (a repo appeared) — evict before re-resolving.
202+
if let Ok(mut cache) = cache().write() {
203+
if let Some(identity) = cache.get(&canonical) {
204+
if identity.starts_with("dir:") && has_git_dir(&canonical) {
205+
cache.remove(&canonical);
206+
}
172207
}
173208
}
174209

0 commit comments

Comments
 (0)