Skip to content

Commit 5e0a895

Browse files
committed
mason: use recorded dashboard project identities
1 parent 66fd4b9 commit 5e0a895

10 files changed

Lines changed: 548 additions & 799 deletions

File tree

packages/dashboard/src-tauri/Cargo.lock

Lines changed: 0 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/dashboard/src-tauri/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ lazy_static = "1"
2525
tokio = { version = "1", features = ["fs", "io-util", "sync", "macros", "process", "time", "rt-multi-thread", "net"] }
2626
reqwest = { version = "0.12", features = ["json"] }
2727
md5 = "0.7"
28-
wait-timeout = "0.2"
2928
tauri-plugin-updater = "2"
3029
tauri-plugin-process = "2"
3130
tauri-plugin-dialog = "2"

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ pub fn get_projects(state: State<'_, AppState>) -> Result<Vec<db::ProjectInfo>,
2222
}
2323

2424
#[tauri::command(async)]
25+
#[allow(clippy::too_many_arguments)]
2526
pub fn get_memories(
2627
state: State<'_, AppState>,
2728
project: Option<String>,
@@ -1117,6 +1118,7 @@ pub async fn get_available_pi_models() -> Vec<String> {
11171118
///
11181119
/// 3. 401/403 → `AuthFailed` (specific "credentials rejected" message).
11191120
/// 404/405 → `EndpointUnsupported` (wrong URL / no embeddings API).
1121+
///
11201122
/// Expand an `{env:}`/`{file:}` token in one probe field, mapping a failure to
11211123
/// the matching probe outcome. The embedding fields come from the USER-level
11221124
/// config editor, so expanding the user's own tokens to test their endpoint is

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ pub fn read_config(path: &PathBuf, source: &str) -> ConfigFile {
106106
}
107107
}
108108

109-
pub fn write_config(path: &PathBuf, content: &str) -> Result<(), String> {
109+
pub fn write_config(path: &Path, content: &str) -> Result<(), String> {
110110
write_config_atomic(path, content, None)
111111
}
112112

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

Lines changed: 479 additions & 147 deletions
Large diffs are not rendered by default.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,7 @@ fn is_cloud_metadata_ip(ip: IpAddr) -> bool {
498498
v6 == Ipv6Addr::new(0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x254)
499499
|| v6
500500
.to_ipv4_mapped()
501-
.map_or(false, |m| m.octets() == [169, 254, 169, 254])
501+
.is_some_and(|m| m.octets() == [169, 254, 169, 254])
502502
}
503503
}
504504
}

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
//! Cross-platform helper to suppress the console window that Windows spawns for
2-
//! every child process. The dashboard shells out to `git` (once per project when
3-
//! resolving identities), `opencode`, and `pi`; without `CREATE_NO_WINDOW` each
4-
//! spawn flashes a console window, so opening the dashboard on Windows can pop a
5-
//! dozen-plus terminals (issue #115).
2+
//! every child process. The dashboard shells out to external tools such as
3+
//! `opencode` and `pi`; without `CREATE_NO_WINDOW` each spawn flashes a console
4+
//! window, so opening the dashboard on Windows can pop a dozen-plus terminals.
65
//!
76
//! On non-Windows targets these are no-ops, so call sites stay uniform.
87
Lines changed: 31 additions & 230 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,4 @@
1-
use std::collections::HashMap;
21
use std::path::{Component, Path, PathBuf};
3-
use std::process::{Command, Stdio};
4-
5-
use crate::process_ext::NoWindowExt;
6-
use std::sync::{OnceLock, RwLock};
7-
use std::time::Duration;
8-
9-
use wait_timeout::ChildExt;
10-
11-
const GIT_TIMEOUT: Duration = Duration::from_secs(5);
12-
13-
static IDENTITY_CACHE: OnceLock<RwLock<HashMap<PathBuf, String>>> = OnceLock::new();
14-
15-
fn cache() -> &'static RwLock<HashMap<PathBuf, String>> {
16-
IDENTITY_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
17-
}
18-
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-
}
252

263
/// Lexically resolve `input` against `cwd`, matching Node's `path.resolve` semantics.
274
///
@@ -72,174 +49,6 @@ pub fn logical_absolute(input: &Path, cwd: &Path) -> PathBuf {
7249
base
7350
}
7451

75-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76-
pub enum IdentityErrorClass {
77-
NotGitRepo,
78-
/// The directory does not exist / is not reachable. Deterministic: the same
79-
/// missing path always yields the same `dir:` fallback, so it is safe to
80-
/// cache (mirrors the TS "Unable to access project directory" fallback).
81-
PathInaccessible,
82-
GitMissing,
83-
GitTimeout,
84-
PermissionDenied,
85-
Unknown,
86-
}
87-
88-
impl IdentityErrorClass {
89-
/// Whether this failure is DETERMINISTIC (same input always reproduces it),
90-
/// so a `dir:` fallback may be cached. Transient classes (git binary
91-
/// missing, timeout, permission, unknown spawn/wait failures) must NOT be
92-
/// cached — a retry could resolve the real `git:` identity. This mirrors the
93-
/// TS resolver, which only falls back for `not_git_repo` + inaccessible-path
94-
/// and never caches transient failures.
95-
fn is_deterministic_fallback(self) -> bool {
96-
matches!(
97-
self,
98-
IdentityErrorClass::NotGitRepo | IdentityErrorClass::PathInaccessible
99-
)
100-
}
101-
}
102-
103-
pub fn resolve_project_identity_strict(directory: &Path) -> Result<String, IdentityErrorClass> {
104-
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
105-
let canonical = logical_absolute(directory, &cwd);
106-
107-
// If the cwd itself is missing, the git spawn would also return NotFound; distinguish
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-
}
122-
}
123-
124-
let mut child = Command::new("git")
125-
.args(["rev-list", "--max-parents=0", "HEAD"])
126-
.current_dir(&canonical)
127-
.env("LC_ALL", "C")
128-
.env("LANG", "C")
129-
.stdout(Stdio::piped())
130-
.stderr(Stdio::piped())
131-
.no_window()
132-
.spawn()
133-
.map_err(|error| match error.kind() {
134-
std::io::ErrorKind::NotFound => IdentityErrorClass::GitMissing,
135-
std::io::ErrorKind::PermissionDenied => IdentityErrorClass::PermissionDenied,
136-
_ => IdentityErrorClass::Unknown,
137-
})?;
138-
139-
let status = match child
140-
.wait_timeout(GIT_TIMEOUT)
141-
.map_err(|_| IdentityErrorClass::Unknown)?
142-
{
143-
Some(status) => status,
144-
None => {
145-
let _ = child.kill();
146-
let _ = child.wait();
147-
return Err(IdentityErrorClass::GitTimeout);
148-
}
149-
};
150-
151-
let output = child
152-
.wait_with_output()
153-
.map_err(|_| IdentityErrorClass::Unknown)?;
154-
155-
if status.success() {
156-
let stdout = String::from_utf8_lossy(&output.stdout);
157-
let first_line = stdout.lines().next().unwrap_or("").trim();
158-
if first_line.len() < 7 {
159-
return Err(IdentityErrorClass::Unknown);
160-
}
161-
// TS accepts the complete root hash line. SHA-1 repos produce 40 chars;
162-
// SHA-256 repos produce 64. Cap at 64 to avoid accepting accidental noise.
163-
let sha = first_line.chars().take(64).collect::<String>();
164-
return Ok(format!("git:{sha}"));
165-
}
166-
167-
let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
168-
if stderr.contains("not a git repository") {
169-
Err(IdentityErrorClass::NotGitRepo)
170-
} else if stderr.contains("permission denied") {
171-
Err(IdentityErrorClass::PermissionDenied)
172-
} else {
173-
Err(IdentityErrorClass::Unknown)
174-
}
175-
}
176-
177-
/// Resolve a raw filesystem path to the stable project identity used by the TS plugin.
178-
///
179-
/// Mirrors `packages/plugin/src/features/magic-context/memory/project-identity.ts`:
180-
/// logical absolute path resolution, `git rev-list --max-parents=0 HEAD`, and
181-
/// `dir:<md5-12>` fallback over the resolved UTF-8 path bytes.
182-
pub fn resolve_project_identity<P: AsRef<Path>>(directory: P) -> String {
183-
let directory = directory.as_ref();
184-
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
185-
let canonical = logical_absolute(directory, &cwd);
186-
187-
if let Ok(cache) = cache().read() {
188-
if let Some(identity) = cache.get(&canonical) {
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-
}
207-
}
208-
}
209-
210-
match resolve_project_identity_strict(&canonical) {
211-
Ok(identity) => {
212-
if let Ok(mut cache) = cache().write() {
213-
cache.insert(canonical, identity.clone());
214-
}
215-
identity
216-
}
217-
Err(error) => {
218-
let fallback = directory_fallback(&canonical);
219-
// Only cache the fallback for DETERMINISTIC failures (not-git /
220-
// inaccessible path). Transient failures (git missing/timeout/
221-
// permission/unknown) return the fallback UNCACHED so a later call
222-
// can still resolve the real `git:` identity once the transient
223-
// condition clears — caching here would pin a wrong `dir:` identity
224-
// for the whole process and mis-group the project in the UI. This
225-
// matches the TS resolver's fallback/propagation policy (the
226-
// dashboard degrades to a fallback instead of throwing because it is
227-
// a read-only viewer that must still render something).
228-
if error.is_deterministic_fallback() {
229-
if let Ok(mut cache) = cache().write() {
230-
cache.insert(canonical, fallback.clone());
231-
}
232-
} else {
233-
eprintln!(
234-
"[dashboard] resolve_project_identity transient error {:?} on {:?} (uncached fallback)",
235-
error, canonical
236-
);
237-
}
238-
fallback
239-
}
240-
}
241-
}
242-
24352
fn directory_fallback(path: &Path) -> String {
24453
let digest = md5::compute(path.to_string_lossy().as_bytes());
24554
let hex = format!("{digest:x}");
@@ -249,13 +58,16 @@ fn directory_fallback(path: &Path) -> String {
24958
/// Normalize a value read from `memories.project_path` / related stored identity columns.
25059
///
25160
/// Stored DB values may already be identities (`git:*`, `dir:*`). Those must be returned
252-
/// unchanged; passing them through `resolve_project_identity` would hash the identity text as
253-
/// a filesystem path and corrupt the project_state key.
61+
/// unchanged; hashing the identity text as a filesystem path would produce a wrong project
62+
/// key. Legacy raw paths degrade to the deterministic directory fallback so the dashboard
63+
/// never probes git while reading historical rows.
25464
pub fn normalize_stored_project_path(raw_or_stored: &str) -> String {
25565
if raw_or_stored.starts_with("git:") || raw_or_stored.starts_with("dir:") {
25666
return raw_or_stored.to_string();
25767
}
258-
resolve_project_identity(Path::new(raw_or_stored))
68+
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
69+
let canonical = logical_absolute(Path::new(raw_or_stored), &cwd);
70+
directory_fallback(&canonical)
25971
}
26072

26173
pub fn basename(path: &str) -> String {
@@ -266,56 +78,45 @@ pub fn basename(path: &str) -> String {
26678
.unwrap_or_else(|| path.to_string())
26779
}
26880

269-
#[doc(hidden)]
270-
pub fn clear_cache_for_tests() {
271-
if let Ok(mut cache) = cache().write() {
272-
cache.clear();
273-
}
274-
}
275-
27681
#[cfg(test)]
27782
mod tests {
27883
use super::*;
27984

85+
fn expected_dir_identity(path: &Path) -> String {
86+
let digest = md5::compute(path.to_string_lossy().as_bytes());
87+
let hex = format!("{digest:x}");
88+
format!("dir:{}", &hex[..12])
89+
}
90+
28091
#[test]
281-
fn resolves_current_repo_as_git_identity() {
282-
clear_cache_for_tests();
283-
let identity = resolve_project_identity(".");
284-
assert!(identity.starts_with("git:"), "{identity}");
285-
assert!(identity.len() > 11);
92+
fn normalize_stored_project_path_preserves_identity_values() {
93+
assert_eq!(normalize_stored_project_path("git:abc123"), "git:abc123");
94+
assert_eq!(
95+
normalize_stored_project_path("dir:deadbeef0000"),
96+
"dir:deadbeef0000"
97+
);
28698
}
28799

288100
#[test]
289-
fn non_git_directory_uses_md5_dir_identity() {
290-
clear_cache_for_tests();
101+
fn normalize_stored_project_path_hashes_raw_paths_without_git() {
291102
let dir = tempfile::tempdir().unwrap();
292-
let identity = resolve_project_identity(dir.path());
293-
assert!(identity.starts_with("dir:"), "{identity}");
294-
assert_eq!(identity.len(), 16);
103+
let identity = normalize_stored_project_path(dir.path().to_str().unwrap());
104+
assert_eq!(identity, expected_dir_identity(dir.path()));
295105
}
296106

297107
#[test]
298-
fn only_deterministic_failures_are_cacheable() {
299-
// Mirrors the TS resolver: not-git and inaccessible-path are
300-
// deterministic (cacheable `dir:` fallback); transient failures
301-
// (git missing/timeout/permission/unknown) must NOT be cached so a
302-
// retry can still resolve the real `git:` identity.
303-
assert!(IdentityErrorClass::NotGitRepo.is_deterministic_fallback());
304-
assert!(IdentityErrorClass::PathInaccessible.is_deterministic_fallback());
305-
assert!(!IdentityErrorClass::GitMissing.is_deterministic_fallback());
306-
assert!(!IdentityErrorClass::GitTimeout.is_deterministic_fallback());
307-
assert!(!IdentityErrorClass::PermissionDenied.is_deterministic_fallback());
308-
assert!(!IdentityErrorClass::Unknown.is_deterministic_fallback());
108+
fn relative_raw_paths_are_resolved_logically_before_hashing() {
109+
let cwd = std::env::current_dir().unwrap();
110+
let resolved = logical_absolute(Path::new("relative/project"), &cwd);
111+
assert_eq!(
112+
normalize_stored_project_path("relative/project"),
113+
expected_dir_identity(&resolved)
114+
);
309115
}
310116

311117
#[test]
312-
fn inaccessible_path_classifies_deterministically() {
313-
let missing = std::env::temp_dir().join("mc-nonexistent-проект-xyz-987654321");
314-
let err = resolve_project_identity_strict(&missing).unwrap_err();
315-
assert_eq!(err, IdentityErrorClass::PathInaccessible);
316-
// Deterministic → resolve_project_identity returns a cached dir: fallback.
317-
clear_cache_for_tests();
318-
let identity = resolve_project_identity(&missing);
319-
assert!(identity.starts_with("dir:"), "{identity}");
118+
fn basename_uses_last_path_component_when_present() {
119+
assert_eq!(basename("/tmp/example"), "example");
120+
assert_eq!(basename("/"), "/");
320121
}
321122
}

0 commit comments

Comments
 (0)