Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/maple-agent/app/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,8 @@ operations! {
select_project_root(user_id: (&str), path: (String)) -> SelectedProject;
browse_project_folders(user_id: (&str), request: (ProjectFolderRequest)) -> ProjectFolderPage;
create_project_folder(user_id: (&str), parent_path: (String), name: (String)) -> String;
create_scratch_project(user_id: (&str)) -> SelectedProject;
recent_project_roots(user_id: (&str)) -> Vec<RecentProjectRoot>;
rename_project_root(user_id: (&str), path: (String), name: (String)) -> RecentProjectRoot;
archive_project_root(user_id: (&str), path: (String), fallback: (Option<String>)) -> ();
create_session(user_id: (&str), request: (Option<AgentCreateSessionRequest>)) -> AgentSessionDetail;
Expand Down
25 changes: 18 additions & 7 deletions apps/maple-agent/app/src/backend/paging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,10 +347,10 @@ fn ensure_workspace_project(
.as_ref()
.is_none_or(|project| project.removed_ms.is_none() && project.position.is_none())
{
let now = chrono::Utc::now().timestamp_millis();
// Switcher pages include explicit positions or task-bearing roots.
// The offered empty workspace is an explicit choice at the end.
store.remember_project(account, &root, i64::MAX, now)?;
// Offering the empty workspace is not a use. Preserve existing
// recency when assigning an unpositioned workspace its list rank.
let last_used_ms = project.as_ref().map_or(0, |project| project.last_used_ms);
store.remember_project(account, &root, i64::MAX, last_used_ms)?;
}
}
Ok(())
Expand Down Expand Up @@ -826,9 +826,20 @@ mod tests {
page.projects[0].root.as_str(),
workspace.to_string_lossy().as_ref()
);
store
.remove_project(account, &page.projects[0].root, 2)
.unwrap();
let root = &page.projects[0].root;
let offered = store.project(account, root).unwrap().unwrap();
assert_eq!(offered.last_used_ms, 0);
assert!(store.recently_used_projects(account).unwrap().is_empty());
store.register_explicit_project(account, root, 10).unwrap();
let selected = store.project(account, root).unwrap().unwrap();
assert_eq!(selected.position, offered.position);
ensure_workspace_project(&store, account).unwrap();
assert_eq!(store.project(account, root).unwrap().unwrap(), selected);
assert_eq!(
store.recently_used_projects(account).unwrap(),
vec![selected]
);
store.remove_project(account, root, 20).unwrap();
ensure_workspace_project(&store, account).unwrap();
assert!(
store
Expand Down
212 changes: 211 additions & 1 deletion apps/maple-agent/app/src/backend/project_folders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use std::{

use serde::{Deserialize, Serialize};

use super::LocalBackend;
use super::{LocalBackend, SelectedProject};

const MAX_PATH_BYTES: usize = 4096;
const MAX_NAME_BYTES: usize = 1024;
Expand Down Expand Up @@ -48,6 +48,19 @@ pub struct ProjectFolderPage {
}

impl LocalBackend {
pub async fn recent_project_roots(
&self,
user_id: &str,
) -> Result<Vec<maple_agent::agent::RecentProjectRoot>, String> {
self.authorize_account(user_id)?;
self.open_store()?;
self.service
.handle_for_user(user_id)
.await?
.recent_project_roots()
.await
}

pub async fn browse_project_folders(
&self,
user_id: &str,
Expand All @@ -60,6 +73,45 @@ impl LocalBackend {
.map_err(|_| "Folder browser worker did not complete".to_string())?
}

/// Create on the execution host, then register with the ordinary project
/// metadata and trust flow. No user-supplied path or prompt names the folder.
pub async fn create_scratch_project(&self, user_id: &str) -> Result<SelectedProject, String> {
self.authorize_account(user_id)?;
self.open_store()?;
let parent = tokio::task::spawn_blocking(|| {
maple_agent::agent::default_maple_workspace_path()
.ok_or_else(|| "This computer's documents folder is unavailable".to_string())
})
.await
.map_err(|_| "Project creation worker did not complete".to_string())??;
self.create_scratch_project_in(user_id, parent).await
}

// Private location seam keeps tests off the real Documents directory. The
// host-facing operation above always chooses this execution host's default.
async fn create_scratch_project_in(
&self,
user_id: &str,
parent: PathBuf,
) -> Result<SelectedProject, String> {
self.authorize_account(user_id)?;
self.open_store()?;
let handle = self.service.handle_for_user(user_id).await?;
let path = tokio::task::spawn_blocking(move || {
create_scratch_folder(&parent, || uuid::Uuid::new_v4().to_string())
})
.await
.map_err(|_| "Project creation worker did not complete".to_string())??;
let project_root = handle.save_recent_project_root(path).await?;
let project = handle
.rename_project_root(project_root.clone(), "Untitled project".into())
.await?;
Ok(SelectedProject {
project_root,
project,
})
}

pub async fn create_project_folder(
&self,
user_id: &str,
Expand Down Expand Up @@ -221,6 +273,27 @@ fn validate_child_name(name: &str) -> Result<(), String> {
Ok(())
}

fn create_scratch_folder(
parent: &Path,
mut unique_suffix: impl FnMut() -> String,
) -> Result<String, String> {
path_text(parent)?;
fs::create_dir_all(parent).map_err(folder_error)?;
let parent = canonical_folder(parent)?;
for _ in 0..4 {
let path = parent.join(format!("Untitled project-{}", unique_suffix()));
path_text(&path)?;
// Atomic creation rejects existing directories, files and symlinks.
// Never adopt a collision, and never delete files after a later error.
match fs::create_dir(&path) {
Ok(()) => return path_text(&canonical_folder(&path)?),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(folder_error(error)),
}
}
Err("Could not create a unique project folder. Try again.".into())
}

fn create(parent: &str, name: &str) -> Result<String, String> {
validate_child_name(name)?;
let parent = canonical_folder(absolute_path(parent)?)?;
Expand All @@ -245,6 +318,143 @@ mod tests {
}
}

#[tokio::test]
async fn scratch_project_registration_persists_and_can_be_named_later() {
const USER: &str = "scratch-project-owner";
let storage = tempfile::tempdir().unwrap();
let folders = tempfile::tempdir().unwrap();
let parent = folders.path().join("Documents").join("Maple");
let backend = LocalBackend::isolated_for_test(storage.path());
assert!(backend.persisted_auth.claim_and_persist(
&maple_agent::maple_api::MapleApiAuthSnapshot {
user_id: USER.into(),
access_token: "fixture-token".into(),
refresh_token: None,
native_instance_id: "fixture-native".into(),
session_id: "fixture-session".into(),
revision: 1,
}
));
assert!(
backend
.create_scratch_project_in("another-account", parent.clone())
.await
.is_err()
);
assert!(
!parent.exists(),
"rejection must precede filesystem effects"
);
let first = backend
.create_scratch_project_in(USER, parent.clone())
.await
.unwrap();
let second = backend
.create_scratch_project_in(USER, parent.clone())
.await
.unwrap();
assert_ne!(first.project_root, second.project_root);
for selected in [&first, &second] {
assert_eq!(selected.project.name, "Untitled project");
assert_eq!(selected.project.path, selected.project_root);
assert!(!selected.project.is_default_workspace);
assert_eq!(
Path::new(&selected.project_root).parent(),
Some(parent.canonicalize().unwrap().as_path())
);
assert_eq!(fs::read_dir(&selected.project_root).unwrap().count(), 0);
}
let renamed = backend
.rename_project_root(USER, first.project_root.clone(), "My new project".into())
.await
.unwrap();
assert_eq!(renamed.name, "My new project");
let snapshot = crate::backend::dispatch(
&backend,
crate::host::protocol::Request {
id: 1,
method: "navigation_bootstrap".into(),
send_id: None,
params: serde_json::json!([USER]),
},
)
.await
.unwrap();
let snapshot: crate::backend::paging::NavigationBootstrap =
serde_json::from_value(snapshot).unwrap();
for (selected, name) in [(&first, "My new project"), (&second, "Untitled project")] {
let project = snapshot
.recent_projects
.iter()
.find(|project| project.root == selected.project_root)
.unwrap();
assert_eq!(project.display_name.as_deref(), Some(name));
let reselected = backend
.select_project_root(USER, selected.project_root.clone())
.await
.unwrap();
assert_eq!(reselected.project.name, name);
}
}

#[test]
fn scratch_projects_are_distinct_empty_persistent_children() {
let root = tempfile::tempdir().unwrap();
let parent = root.path().join("Documents").join("Maple");
let first = create_scratch_folder(&parent, || uuid::Uuid::new_v4().to_string()).unwrap();
let second = create_scratch_folder(&parent, || uuid::Uuid::new_v4().to_string()).unwrap();
assert_ne!(first, second);
for project in [first, second] {
let path = Path::new(&project);
assert_eq!(
path.parent(),
Some(parent.canonicalize().unwrap().as_path())
);
assert!(
path.file_name()
.unwrap()
.to_str()
.unwrap()
.starts_with("Untitled project-")
);
assert_eq!(fs::read_dir(path).unwrap().count(), 0);
}
}

#[test]
fn scratch_projects_skip_collisions_and_fail_without_overwriting() {
let root = tempfile::tempdir().unwrap();
fs::create_dir(root.path().join("Untitled project-folder")).unwrap();
let file = root.path().join("Untitled project-file");
fs::write(&file, b"keep").unwrap();
let mut suffixes = ["folder", "file", "fresh"].into_iter();
let created =
create_scratch_folder(root.path(), || suffixes.next().unwrap().into()).unwrap();
assert!(created.ends_with("Untitled project-fresh"));
assert!(create_scratch_folder(root.path(), || "file".into()).is_err());
assert_eq!(fs::read(&file).unwrap(), b"keep");
assert!(create_scratch_folder(&file, || "child".into()).is_err());
assert!(!file.join("Untitled project-child").exists());
}

#[cfg(unix)]
#[test]
fn scratch_projects_never_adopt_existing_symlinks() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(outside.path(), root.path().join("Untitled project-link"))
.unwrap();
std::os::unix::fs::symlink(
root.path().join("missing"),
root.path().join("Untitled project-broken"),
)
.unwrap();
for suffix in ["link", "broken"] {
assert!(create_scratch_folder(root.path(), || suffix.into()).is_err());
}
assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
}

#[test]
fn folder_pages_default_to_host_home_and_page_sorted_directories() {
let root = tempfile::tempdir().unwrap();
Expand Down
29 changes: 29 additions & 0 deletions apps/maple-agent/app/src/backend/project_names_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,19 @@ async fn project_rename_ipc_preserves_account_boundary_and_recovery_metadata() {
assert_eq!(renamed.name, "Owner project");
assert!(renamed.name_revision > 0);
project_event(&mut events, &path, "Owner project").await;
let value = crate::backend::dispatch(&backend, request("recent_project_roots", json!([USER])))
.await
.unwrap();
let recent: Vec<RecentProjectRoot> = serde_json::from_value(value).unwrap();
assert_eq!(recent.len(), 1);
assert_eq!(recent[0].path, path);
assert_eq!(recent[0].name, "Owner project");
assert!(recent[0].last_used_ms > 0);

for method in [
"rename_project_root",
"navigation_bootstrap",
"recent_project_roots",
"list_projects",
] {
let params = match method {
Expand Down Expand Up @@ -395,6 +404,25 @@ async fn project_folder_ipc_and_iroh_use_owner_paths_and_preserve_account_guards
.is_err()
);
}
assert!(
crate::backend::dispatch(
&backend,
request("create_scratch_project", json!(["another-account"]))
)
.await
.is_err()
);
assert!(
remote_call(
&backend,
&client,
&server,
wire::Method::CreateScratchProject,
json!(["another-account"])
)
.await
.is_err()
);
assert!(!folder.path().join("unauthorized").exists());
assert!(
remote_call(
Expand Down Expand Up @@ -433,6 +461,7 @@ async fn project_folder_operations_require_an_open_store() {
json!([USER, {"path":path,"after":null,"limit":10}]),
),
("create_project_folder", json!([USER, path, "locked-child"])),
("create_scratch_project", json!([USER])),
] {
assert!(
crate::backend::dispatch(&backend, request(method, params))
Expand Down
Loading