1use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use async_trait::async_trait;
12use bamboo_agent_core::Session;
13use bamboo_domain::{ProjectId, ProjectResourceKind, ProjectResourceSummary, WorkspaceBinding};
14use serde::{Deserialize, Serialize};
15
16pub const PROJECT_ID_METADATA_KEY: &str = "project_id";
17pub const PROJECT_RESOURCES_RENDERED_KEY: &str = "project_resources_rendered";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum WorkspaceBindingStatus {
22 Registered,
23 Unregistered,
24}
25
26impl WorkspaceBindingStatus {
27 pub const fn as_str(self) -> &'static str {
28 match self {
29 Self::Registered => "registered",
30 Self::Unregistered => "unregistered",
31 }
32 }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ProjectDescriptor {
37 pub id: ProjectId,
38 pub name: String,
39 pub home: PathBuf,
40 pub workspace_bindings: Vec<WorkspaceBinding>,
41 pub resources: ProjectResourceSummary,
42 pub memory_read_roots: ProjectMemoryReadRoots,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct ProjectMemoryReadRoots {
47 pub primary: PathBuf,
48 pub legacy_aliases: Vec<bamboo_memory::memory_store::LegacyProjectMemoryReadRoot>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ResolvedProjectContext {
53 pub project: ProjectDescriptor,
54 pub workspace: Option<PathBuf>,
55 pub binding_status: WorkspaceBindingStatus,
56}
57
58impl ResolvedProjectContext {
59 pub fn resource_scope(&self) -> ProjectResourceScope {
60 ProjectResourceScope {
61 project_id: self.project.id.clone(),
62 project_home: self.project.home.clone(),
63 workspace: self.workspace.clone(),
64 binding_status: self.binding_status,
65 resource_revision: self.project.resources.resource_revision,
66 }
67 }
68
69 pub fn render_resource_inventory(&self) -> String {
70 let mut entries = self.project.resources.resources.clone();
71 entries.sort_by_key(|entry| entry.kind);
72 let rendered = entries
73 .into_iter()
74 .map(|entry| {
75 format!(
76 "- {:?}: status={}, items={}",
77 entry.kind,
78 if entry.present { "available" } else { "empty" },
79 entry.item_count
80 )
81 })
82 .collect::<Vec<_>>()
83 .join("\n");
84 format!(
85 "Project ID: {}\nResource revision: {}\n{}",
86 self.project.id,
87 self.project.resources.resource_revision,
88 if rendered.is_empty() {
89 "No Project-shared resources are currently advertised.".to_string()
90 } else {
91 rendered
92 }
93 )
94 }
95}
96
97#[derive(Debug, thiserror::Error)]
98pub enum ProjectContextError {
99 #[error("project context source failed: {0}")]
100 Source(String),
101 #[error("project context source returned '{actual}' for requested project '{requested}'")]
102 IdentityMismatch { requested: String, actual: String },
103 #[error(
104 "workspace '{workspace}' belongs to Project '{owner_project_id}', not session Project '{session_project_id}'"
105 )]
106 WorkspaceConflict {
107 workspace: String,
108 owner_project_id: ProjectId,
109 session_project_id: ProjectId,
110 },
111 #[error("workspace '{workspace}' belongs to Project '{owner_project_id}', but the session is Unassigned")]
112 UnassignedWorkspaceConflict {
113 workspace: String,
114 owner_project_id: ProjectId,
115 },
116 #[error("session carries an invalid Project identity '{raw}': {message}")]
117 InvalidProjectIdentity { raw: String, message: String },
118 #[error("assigned Project '{project_id}' is unavailable")]
119 ProjectUnavailable { project_id: ProjectId },
120 #[error("workspace '{workspace}' is invalid: {message}")]
121 WorkspaceInvalid { workspace: String, message: String },
122}
123
124#[async_trait]
130pub trait ProjectContextSource: Send + Sync {
131 async fn find_project(
132 &self,
133 project_id: &ProjectId,
134 ) -> Result<Option<ProjectDescriptor>, ProjectContextError>;
135
136 async fn list_projects(&self) -> Result<Vec<ProjectDescriptor>, ProjectContextError> {
137 Ok(Vec::new())
138 }
139
140 async fn find_workspace_owner(
143 &self,
144 _workspace: &Path,
145 ) -> Result<Option<ProjectId>, ProjectContextError> {
146 Ok(None)
147 }
148}
149
150#[derive(Clone)]
151pub struct ProjectContextResolver {
152 source: Arc<dyn ProjectContextSource>,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum SessionProjectIdentity {
157 Unassigned,
158 Assigned(ProjectId),
159 Invalid { raw: String, message: String },
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub enum ProjectMemoryScope {
164 Assigned {
165 project_id: ProjectId,
166 legacy_aliases: Vec<bamboo_memory::memory_store::LegacyProjectMemoryReadRoot>,
167 },
168 LegacyReadOnly(String),
169}
170
171impl ProjectMemoryScope {
172 pub fn key(&self) -> &str {
173 match self {
174 Self::Assigned { project_id, .. } => project_id.as_str(),
175 Self::LegacyReadOnly(project_key) => project_key,
176 }
177 }
178
179 pub fn scoped_store(
180 &self,
181 store: &bamboo_memory::memory_store::MemoryStore,
182 ) -> bamboo_memory::memory_store::MemoryStore {
183 match self {
184 Self::Assigned {
185 project_id,
186 legacy_aliases,
187 } => store.for_project_with_legacy_read_roots(project_id, legacy_aliases.clone()),
188 Self::LegacyReadOnly(_) => store.clone(),
189 }
190 }
191}
192
193impl ProjectContextResolver {
194 pub fn new(source: Arc<dyn ProjectContextSource>) -> Self {
195 Self { source }
196 }
197
198 pub fn project_id_from_session(session: &Session) -> Option<ProjectId> {
204 match Self::session_project_identity(session) {
205 SessionProjectIdentity::Assigned(project_id) => Some(project_id),
206 SessionProjectIdentity::Invalid { raw, message } => {
207 tracing::warn!(
208 session_id = %session.id,
209 "ignoring invalid persisted Project id '{raw}': {message}"
210 );
211 None
212 }
213 SessionProjectIdentity::Unassigned => None,
214 }
215 }
216
217 pub fn session_project_identity(session: &Session) -> SessionProjectIdentity {
222 let Some(raw) = session.project_id_meta() else {
223 return SessionProjectIdentity::Unassigned;
224 };
225 let normalized = raw.trim();
226 match ProjectId::parse(normalized) {
227 Ok(project_id) => SessionProjectIdentity::Assigned(project_id),
228 Err(error) => SessionProjectIdentity::Invalid {
229 raw,
230 message: error.to_string(),
231 },
232 }
233 }
234
235 pub fn memory_read_scope_for_session(session: &Session) -> Option<String> {
241 Self::memory_read_identity_for_session(session).map(|scope| scope.key().to_string())
242 }
243
244 pub fn memory_read_identity_for_session(session: &Session) -> Option<ProjectMemoryScope> {
245 match Self::session_project_identity(session) {
246 SessionProjectIdentity::Assigned(project_id) => Some(ProjectMemoryScope::Assigned {
247 project_id,
248 legacy_aliases: Vec::new(),
249 }),
250 SessionProjectIdentity::Unassigned => session
251 .workspace_path_meta()
252 .map(PathBuf::from)
253 .or_else(|| {
254 bamboo_tools::tools::workspace_state::get_workspace(session.id.as_str())
255 })
256 .map(|path| {
257 ProjectMemoryScope::LegacyReadOnly(
258 bamboo_memory::memory_store::project_key_from_path(&path),
259 )
260 }),
261 SessionProjectIdentity::Invalid { .. } => None,
262 }
263 }
264
265 pub async fn resolve_memory_read_scope(
266 &self,
267 session: &Session,
268 workspace: Option<&Path>,
269 ) -> Result<Option<ProjectMemoryScope>, ProjectContextError> {
270 match Self::session_project_identity(session) {
271 SessionProjectIdentity::Unassigned => {
272 return Ok(Self::memory_read_identity_for_session(session));
273 }
274 SessionProjectIdentity::Invalid { raw, message } => {
275 return Err(ProjectContextError::InvalidProjectIdentity { raw, message });
276 }
277 SessionProjectIdentity::Assigned(_) => {}
278 }
279 Ok(self
280 .resolve(session, workspace)
281 .await?
282 .map(|context| ProjectMemoryScope::Assigned {
283 project_id: context.project.id,
284 legacy_aliases: context.project.memory_read_roots.legacy_aliases,
285 }))
286 }
287
288 pub async fn list_memory_read_scopes(
289 &self,
290 ) -> Result<Vec<ProjectMemoryScope>, ProjectContextError> {
291 Ok(self
292 .source
293 .list_projects()
294 .await?
295 .into_iter()
296 .map(|project| ProjectMemoryScope::Assigned {
297 project_id: project.id,
298 legacy_aliases: project.memory_read_roots.legacy_aliases,
299 })
300 .collect())
301 }
302
303 pub fn memory_write_scope_for_session(session: &Session) -> Option<String> {
309 match Self::session_project_identity(session) {
310 SessionProjectIdentity::Assigned(project_id) => Some(project_id.into_string()),
311 SessionProjectIdentity::Unassigned | SessionProjectIdentity::Invalid { .. } => None,
312 }
313 }
314
315 pub async fn resolve(
316 &self,
317 session: &Session,
318 workspace: Option<&Path>,
319 ) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
320 let workspace = Self::resolve_workspace_candidate(session, workspace)?;
321 self.resolve_with_final_workspace(session, workspace).await
322 }
323
324 pub fn resolve_workspace_candidate(
330 session: &Session,
331 workspace: Option<&Path>,
332 ) -> Result<Option<PathBuf>, ProjectContextError> {
333 let preferred = workspace
334 .map(Path::to_path_buf)
335 .or_else(|| session.workspace_path_meta().map(PathBuf::from));
336 bamboo_agent_core::workspace_state::resolve_session_workspace_candidate(
337 &session.id,
338 preferred,
339 )
340 .map(|candidate| resolve_final_workspace(&candidate))
341 .transpose()
342 }
343
344 async fn resolve_with_final_workspace(
345 &self,
346 session: &Session,
347 workspace: Option<PathBuf>,
348 ) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
349 let project_id = match Self::session_project_identity(session) {
350 SessionProjectIdentity::Assigned(project_id) => project_id,
351 SessionProjectIdentity::Invalid { raw, message } => {
352 return Err(ProjectContextError::InvalidProjectIdentity { raw, message });
353 }
354 SessionProjectIdentity::Unassigned => {
355 if let Some(candidate) = workspace.as_deref() {
356 if let Some(owner_project_id) =
357 self.source.find_workspace_owner(candidate).await?
358 {
359 return Err(ProjectContextError::UnassignedWorkspaceConflict {
360 workspace: candidate.to_string_lossy().into_owned(),
361 owner_project_id,
362 });
363 }
364 }
365 return Ok(None);
366 }
367 };
368 let project = self
369 .source
370 .find_project(&project_id)
371 .await?
372 .ok_or_else(|| ProjectContextError::ProjectUnavailable {
373 project_id: project_id.clone(),
374 })?;
375 if project.id != project_id {
376 return Err(ProjectContextError::IdentityMismatch {
377 requested: project_id.to_string(),
378 actual: project.id.to_string(),
379 });
380 }
381
382 let binding_status = match workspace.as_deref() {
383 Some(candidate) => match self.source.find_workspace_owner(candidate).await? {
384 Some(owner) if owner == project.id => WorkspaceBindingStatus::Registered,
385 Some(owner) => {
386 return Err(ProjectContextError::WorkspaceConflict {
387 workspace: candidate.to_string_lossy().into_owned(),
388 owner_project_id: owner,
389 session_project_id: project.id.clone(),
390 });
391 }
392 None if project
393 .workspace_bindings
394 .iter()
395 .any(|binding| path_is_within_binding(Path::new(&binding.path), candidate)) =>
396 {
397 WorkspaceBindingStatus::Registered
398 }
399 None => WorkspaceBindingStatus::Unregistered,
400 },
401 None => WorkspaceBindingStatus::Unregistered,
402 };
403
404 Ok(Some(ResolvedProjectContext {
405 project,
406 workspace,
407 binding_status,
408 }))
409 }
410
411 pub async fn workspace_owner(
412 &self,
413 workspace: &Path,
414 ) -> Result<Option<ProjectId>, ProjectContextError> {
415 self.source.find_workspace_owner(workspace).await
416 }
417
418 pub async fn refresh_session_prompt(
426 &self,
427 session: &mut Session,
428 ) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
429 self.refresh_session_prompt_inner(session, true).await
430 }
431
432 pub async fn refresh_session_prompt_read_only(
440 &self,
441 session: &mut Session,
442 ) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
443 self.refresh_session_prompt_inner(session, false).await
444 }
445
446 async fn refresh_session_prompt_inner(
447 &self,
448 session: &mut Session,
449 sync_runtime_workspace: bool,
450 ) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
451 let workspace = Self::resolve_workspace_candidate(session, None)?;
452 let resolved = self
453 .resolve_with_final_workspace(session, workspace.clone())
454 .await?;
455 if let Some(workspace) = workspace.as_deref() {
456 let final_workspace = if sync_runtime_workspace {
457 bamboo_tools::tools::workspace_state::publish_resolved_workspace(
458 &session.id,
459 workspace.into(),
460 )
461 } else {
462 workspace.to_path_buf()
463 };
464 session.set_workspace_path_meta(bamboo_config::paths::path_to_display_string(
465 &final_workspace,
466 ));
467 }
468 let current = session
469 .messages
470 .iter()
471 .find(|message| matches!(message.role, bamboo_agent_core::Role::System))
472 .map(|message| message.content.clone())
473 .or_else(|| session.metadata.get("base_system_prompt").cloned())
474 .unwrap_or_default();
475 let mut updated =
476 crate::runtime::context::upsert_project_prompt_context(¤t, resolved.as_ref());
477
478 if let Some(context) = resolved.as_ref() {
479 session.metadata.insert(
480 PROJECT_RESOURCES_RENDERED_KEY.to_string(),
481 context.render_resource_inventory(),
482 );
483 } else {
484 session.metadata.remove(PROJECT_RESOURCES_RENDERED_KEY);
485 }
486 let workspace_display = workspace
487 .as_deref()
488 .map(bamboo_config::paths::path_to_display_string);
489 updated = crate::runtime::context::upsert_workspace_prompt_context(
490 &updated,
491 workspace_display.as_deref(),
492 resolved
493 .as_ref()
494 .map(|context| context.binding_status)
495 .unwrap_or(WorkspaceBindingStatus::Unregistered),
496 );
497
498 if let Some(system_message) = session
499 .messages
500 .iter_mut()
501 .find(|message| matches!(message.role, bamboo_agent_core::Role::System))
502 {
503 system_message.content = updated;
504 } else if !updated.trim().is_empty() {
505 session
506 .messages
507 .insert(0, bamboo_agent_core::Message::system(updated));
508 }
509 crate::runner::refresh_prompt_snapshot(session);
510
511 Ok(resolved)
512 }
513}
514
515fn resolve_final_workspace(workspace: &Path) -> Result<PathBuf, ProjectContextError> {
516 if workspace.exists() && !workspace.is_dir() {
517 return Err(ProjectContextError::WorkspaceInvalid {
518 workspace: workspace.to_string_lossy().into_owned(),
519 message: "path is not a directory".to_string(),
520 });
521 }
522 let canonical = std::fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
523 let final_workspace = bamboo_agent_core::workspace_state::preview_workspace_path(canonical);
524 if final_workspace.exists() && !final_workspace.is_dir() {
525 return Err(ProjectContextError::WorkspaceInvalid {
526 workspace: final_workspace.to_string_lossy().into_owned(),
527 message: "resolved path is not a directory".to_string(),
528 });
529 }
530 Ok(std::fs::canonicalize(&final_workspace).unwrap_or(final_workspace))
531}
532
533fn path_is_within_binding(binding: &Path, candidate: &Path) -> bool {
534 match (
535 std::fs::canonicalize(binding),
536 std::fs::canonicalize(candidate),
537 ) {
538 (Ok(binding), Ok(candidate)) => candidate == binding || candidate.starts_with(binding),
539 _ => candidate == binding || candidate.starts_with(binding),
540 }
541}
542
543#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
544#[serde(rename_all = "snake_case")]
545pub enum ProjectResourceLayer {
546 Project,
547 Workspace,
548}
549
550#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
551pub struct ProjectResourceCandidate {
552 pub kind: ProjectResourceKind,
553 pub layer: ProjectResourceLayer,
554 pub path: PathBuf,
555 pub exists: bool,
556}
557
558#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
559pub struct ProjectResourceDiagnostic {
560 pub project_id: ProjectId,
561 pub resource_revision: u64,
562 pub workspace_binding_status: WorkspaceBindingStatus,
563 pub candidates: Vec<ProjectResourceCandidate>,
564}
565
566#[derive(Debug, Clone, PartialEq, Eq)]
568pub struct ProjectResourceScope {
569 pub project_id: ProjectId,
570 pub project_home: PathBuf,
571 pub workspace: Option<PathBuf>,
572 pub binding_status: WorkspaceBindingStatus,
573 pub resource_revision: u64,
574}
575
576impl ProjectResourceScope {
577 pub fn project_memory_root(&self) -> PathBuf {
578 self.project_home.join("memory").join("v1")
579 }
580
581 pub fn project_skills_dir(&self) -> PathBuf {
582 self.project_home.join("skills")
583 }
584
585 pub fn project_mode_skills_dir(&self, mode: &str) -> PathBuf {
586 self.project_home.join(format!("skills-{mode}"))
587 }
588
589 pub fn workspace_skills_dir(&self) -> Option<PathBuf> {
590 self.workspace
591 .as_ref()
592 .map(|workspace| workspace.join(".bamboo").join("skills"))
593 }
594
595 pub fn workspace_mode_skills_dir(&self, mode: &str) -> Option<PathBuf> {
596 self.workspace
597 .as_ref()
598 .map(|workspace| workspace.join(".bamboo").join(format!("skills-{mode}")))
599 }
600
601 pub fn project_commands_dir(&self) -> PathBuf {
602 self.project_home.join("commands")
603 }
604
605 pub fn workspace_commands_dir(&self) -> Option<PathBuf> {
606 let workspace = self.workspace.as_deref()?;
607 let boundary = nearest_git_boundary(workspace).unwrap_or_else(|| workspace.to_path_buf());
608 Some(boundary.join(".bamboo").join("commands"))
609 }
610
611 pub fn candidates(&self, kind: ProjectResourceKind) -> Vec<ProjectResourceCandidate> {
615 let mut paths = match kind {
616 ProjectResourceKind::Settings => vec![(
617 ProjectResourceLayer::Project,
618 self.project_home.join("settings.json"),
619 )],
620 ProjectResourceKind::Memory => {
621 vec![(ProjectResourceLayer::Project, self.project_memory_root())]
622 }
623 ProjectResourceKind::Skills => {
624 let mut values = vec![(ProjectResourceLayer::Project, self.project_skills_dir())];
625 if let Some(path) = self.workspace_skills_dir() {
626 values.push((ProjectResourceLayer::Workspace, path));
627 }
628 values
629 }
630 ProjectResourceKind::Commands => {
631 let mut values = vec![(ProjectResourceLayer::Project, self.project_commands_dir())];
632 if let Some(path) = self.workspace_commands_dir() {
633 values.push((ProjectResourceLayer::Workspace, path));
634 }
635 values
636 }
637 ProjectResourceKind::Artifacts => vec![(
638 ProjectResourceLayer::Project,
639 self.project_home.join("artifacts"),
640 )],
641 ProjectResourceKind::State => vec![(
642 ProjectResourceLayer::Project,
643 self.project_home.join("state"),
644 )],
645 };
646
647 paths
648 .drain(..)
649 .map(|(layer, path)| ProjectResourceCandidate {
650 kind,
651 layer,
652 exists: path.exists(),
653 path,
654 })
655 .collect()
656 }
657
658 pub fn diagnostic(&self) -> ProjectResourceDiagnostic {
659 let mut candidates = Vec::new();
660 for kind in [
661 ProjectResourceKind::Settings,
662 ProjectResourceKind::Memory,
663 ProjectResourceKind::Skills,
664 ProjectResourceKind::Commands,
665 ProjectResourceKind::Artifacts,
666 ProjectResourceKind::State,
667 ] {
668 candidates.extend(self.candidates(kind));
669 }
670 ProjectResourceDiagnostic {
671 project_id: self.project_id.clone(),
672 resource_revision: self.resource_revision,
673 workspace_binding_status: self.binding_status,
674 candidates,
675 }
676 }
677}
678
679fn nearest_git_boundary(start: &Path) -> Option<PathBuf> {
680 start
681 .ancestors()
682 .find(|candidate| {
683 let git = candidate.join(".git");
684 git.is_dir() || git.is_file()
685 })
686 .map(Path::to_path_buf)
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692
693 struct StaticSource(ProjectDescriptor);
694
695 #[async_trait]
696 impl ProjectContextSource for StaticSource {
697 async fn find_project(
698 &self,
699 project_id: &ProjectId,
700 ) -> Result<Option<ProjectDescriptor>, ProjectContextError> {
701 Ok((&self.0.id == project_id).then(|| self.0.clone()))
702 }
703 }
704
705 struct OwnedWorkspaceSource {
706 descriptor: ProjectDescriptor,
707 owner: ProjectId,
708 }
709
710 #[async_trait]
711 impl ProjectContextSource for OwnedWorkspaceSource {
712 async fn find_project(
713 &self,
714 project_id: &ProjectId,
715 ) -> Result<Option<ProjectDescriptor>, ProjectContextError> {
716 Ok((&self.descriptor.id == project_id).then(|| self.descriptor.clone()))
717 }
718
719 async fn find_workspace_owner(
720 &self,
721 _workspace: &Path,
722 ) -> Result<Option<ProjectId>, ProjectContextError> {
723 Ok(Some(self.owner.clone()))
724 }
725 }
726
727 #[tokio::test]
728 async fn workspace_changes_do_not_change_project_identity_or_home() {
729 let directory = tempfile::tempdir().expect("tempdir");
730 let main = directory.path().join("main");
731 let worktree = directory.path().join("worktree");
732 std::fs::create_dir_all(&main).expect("main");
733 std::fs::create_dir_all(&worktree).expect("worktree");
734 let project_id = ProjectId::parse("01JPROJECT00000000000000000").expect("project id");
735 let descriptor = ProjectDescriptor {
736 id: project_id.clone(),
737 name: "Zenith".to_string(),
738 home: directory
739 .path()
740 .join("projects/01JPROJECT00000000000000000"),
741 workspace_bindings: vec![
742 WorkspaceBinding {
743 path: main.to_string_lossy().to_string(),
744 label: Some("main".to_string()),
745 git_common_dir: None,
746 },
747 WorkspaceBinding {
748 path: worktree.to_string_lossy().to_string(),
749 label: Some("worktree".to_string()),
750 git_common_dir: None,
751 },
752 ],
753 resources: bamboo_domain::ProjectResourceSummary {
754 project_id: project_id.clone(),
755 resource_revision: 7,
756 resources: Vec::new(),
757 },
758 memory_read_roots: ProjectMemoryReadRoots {
759 primary: directory
760 .path()
761 .join("projects/01JPROJECT00000000000000000/memory/v1"),
762 legacy_aliases: Vec::new(),
763 },
764 };
765 let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
766 let mut session = Session::new("session-1", "test");
767 session.set_project_id_meta(project_id.to_string());
768
769 let first = resolver
770 .resolve(&session, Some(&main))
771 .await
772 .expect("resolve")
773 .expect("assigned");
774 let second = resolver
775 .resolve(&session, Some(&worktree))
776 .await
777 .expect("resolve")
778 .expect("assigned");
779 assert_eq!(first.project.id, second.project.id);
780 assert_eq!(first.project.home, second.project.home);
781 assert_eq!(second.binding_status, WorkspaceBindingStatus::Registered);
782 }
783
784 #[tokio::test]
785 async fn prompt_refresh_removes_stale_project_and_updates_unassigned_workspace() {
786 let directory = tempfile::tempdir().expect("tempdir");
787 let workspace = directory.path().join("workspace");
788 std::fs::create_dir_all(&workspace).expect("workspace");
789 let project_id = ProjectId::parse("project-prompt-refresh").expect("Project id");
790 let descriptor = ProjectDescriptor {
791 id: project_id.clone(),
792 name: "Prompt Project".to_string(),
793 home: directory.path().join("projects/project-prompt-refresh"),
794 workspace_bindings: vec![WorkspaceBinding {
795 path: workspace.to_string_lossy().into_owned(),
796 label: None,
797 git_common_dir: None,
798 }],
799 resources: bamboo_domain::ProjectResourceSummary {
800 project_id: project_id.clone(),
801 resource_revision: 1,
802 resources: Vec::new(),
803 },
804 memory_read_roots: ProjectMemoryReadRoots {
805 primary: directory
806 .path()
807 .join("projects/project-prompt-refresh/memory/v1"),
808 legacy_aliases: Vec::new(),
809 },
810 };
811 let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
812 let mut session = Session::new("prompt-project-switch", "test");
813 session
814 .messages
815 .insert(0, bamboo_agent_core::Message::system("base"));
816 session.set_project_id_meta(project_id.to_string());
817 session.set_workspace_path_meta(workspace.to_string_lossy().into_owned());
818
819 resolver
820 .refresh_session_prompt(&mut session)
821 .await
822 .expect("assigned refresh");
823 let assigned = &session.messages[0].content;
824 assert_eq!(assigned.matches("BAMBOO_PROJECT_CONTEXT_START").count(), 1);
825 assert!(assigned.contains("Binding status: registered"));
826
827 session.clear_project_id_meta();
828 resolver
829 .refresh_session_prompt(&mut session)
830 .await
831 .expect("unassigned refresh");
832 let unassigned = &session.messages[0].content;
833 assert_eq!(
834 unassigned.matches("BAMBOO_PROJECT_CONTEXT_START").count(),
835 0
836 );
837 assert_eq!(
838 unassigned.matches("BAMBOO_WORKSPACE_CONTEXT_START").count(),
839 1
840 );
841 assert!(unassigned.contains("Binding status: unregistered"));
842 assert!(!session
843 .metadata
844 .contains_key(PROJECT_RESOURCES_RENDERED_KEY));
845 }
846
847 #[tokio::test]
848 async fn workspace_owned_by_another_project_fails_closed() {
849 let directory = tempfile::tempdir().expect("tempdir");
850 let workspace = directory.path().join("workspace");
851 std::fs::create_dir_all(&workspace).expect("workspace");
852 let project_id = ProjectId::parse("project-a").expect("project id");
853 let owner = ProjectId::parse("project-b").expect("owner id");
854 let descriptor = ProjectDescriptor {
855 id: project_id.clone(),
856 name: "Project A".to_string(),
857 home: directory.path().join("projects/project-a"),
858 workspace_bindings: vec![WorkspaceBinding {
859 path: workspace.to_string_lossy().into_owned(),
860 label: None,
861 git_common_dir: None,
862 }],
863 resources: bamboo_domain::ProjectResourceSummary {
864 project_id: project_id.clone(),
865 resource_revision: 1,
866 resources: Vec::new(),
867 },
868 memory_read_roots: ProjectMemoryReadRoots {
869 primary: directory.path().join("projects/project-a/memory/v1"),
870 legacy_aliases: Vec::new(),
871 },
872 };
873 let resolver = ProjectContextResolver::new(Arc::new(OwnedWorkspaceSource {
874 descriptor,
875 owner: owner.clone(),
876 }));
877 let mut session = Session::new("session-conflict", "test");
878 session.set_project_id_meta(project_id.to_string());
879
880 let error = resolver
881 .resolve(&session, Some(&workspace))
882 .await
883 .expect_err("cross-Project workspace must fail closed");
884 assert!(matches!(
885 error,
886 ProjectContextError::WorkspaceConflict {
887 owner_project_id,
888 session_project_id,
889 ..
890 } if owner_project_id == owner && session_project_id == project_id
891 ));
892
893 let safe_workspace = directory.path().join("safe");
894 std::fs::create_dir_all(&safe_workspace).expect("safe workspace");
895 let safe_canonical = safe_workspace.canonicalize().expect("canonical safe");
896 bamboo_tools::tools::workspace_state::set_workspace(&session.id, safe_canonical.clone());
897 session.set_workspace_path_meta(workspace.to_string_lossy().into_owned());
898 let error = resolver
899 .refresh_session_prompt(&mut session)
900 .await
901 .expect_err("refresh must validate before publishing workspace");
902 assert!(matches!(
903 error,
904 ProjectContextError::WorkspaceConflict { .. }
905 ));
906 assert_eq!(
907 bamboo_tools::tools::workspace_state::get_workspace(&session.id).as_deref(),
908 Some(safe_canonical.as_path()),
909 "a post-preflight ownership change must not publish the rejected workspace"
910 );
911 }
912
913 #[tokio::test]
914 async fn malformed_project_identity_never_falls_back_to_legacy_workspace_scope() {
915 let directory = tempfile::tempdir().expect("tempdir");
916 let workspace = directory.path().join("workspace");
917 std::fs::create_dir_all(&workspace).expect("workspace");
918 let descriptor_id = ProjectId::parse("descriptor").expect("Project id");
919 let descriptor = ProjectDescriptor {
920 id: descriptor_id.clone(),
921 name: "Descriptor".to_string(),
922 home: directory.path().join("projects/descriptor"),
923 workspace_bindings: Vec::new(),
924 resources: bamboo_domain::ProjectResourceSummary {
925 project_id: descriptor_id.clone(),
926 resource_revision: 1,
927 resources: Vec::new(),
928 },
929 memory_read_roots: ProjectMemoryReadRoots {
930 primary: directory.path().join("projects/descriptor/memory/v1"),
931 legacy_aliases: Vec::new(),
932 },
933 };
934 let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
935 let mut session = Session::new("malformed", "test");
936 session.set_project_id_meta("../malformed");
937 session.set_workspace_path_meta(workspace.to_string_lossy().into_owned());
938
939 assert!(ProjectContextResolver::memory_read_identity_for_session(&session).is_none());
940 assert!(matches!(
941 resolver.resolve(&session, Some(&workspace)).await,
942 Err(ProjectContextError::InvalidProjectIdentity { .. })
943 ));
944 assert!(matches!(
945 resolver
946 .resolve_memory_read_scope(&session, Some(&workspace))
947 .await,
948 Err(ProjectContextError::InvalidProjectIdentity { .. })
949 ));
950 }
951
952 #[tokio::test]
953 async fn unassigned_session_cannot_resolve_a_project_owned_workspace() {
954 let directory = tempfile::tempdir().expect("tempdir");
955 let workspace = directory.path().join("workspace");
956 std::fs::create_dir_all(&workspace).expect("workspace");
957 let descriptor_id = ProjectId::parse("descriptor").expect("Project id");
958 let owner = ProjectId::parse("owner").expect("owner Project id");
959 let descriptor = ProjectDescriptor {
960 id: descriptor_id.clone(),
961 name: "Descriptor".to_string(),
962 home: directory.path().join("projects/descriptor"),
963 workspace_bindings: Vec::new(),
964 resources: bamboo_domain::ProjectResourceSummary {
965 project_id: descriptor_id,
966 resource_revision: 1,
967 resources: Vec::new(),
968 },
969 memory_read_roots: ProjectMemoryReadRoots {
970 primary: directory.path().join("projects/descriptor/memory/v1"),
971 legacy_aliases: Vec::new(),
972 },
973 };
974 let resolver = ProjectContextResolver::new(Arc::new(OwnedWorkspaceSource {
975 descriptor,
976 owner: owner.clone(),
977 }));
978 let session = Session::new("unassigned", "test");
979
980 assert!(matches!(
981 resolver.resolve(&session, Some(&workspace)).await,
982 Err(ProjectContextError::UnassignedWorkspaceConflict {
983 owner_project_id,
984 ..
985 }) if owner_project_id == owner
986 ));
987 }
988
989 #[test]
990 fn project_identity_parser_trims_like_the_storage_index() {
991 let mut session = Session::new("whitespace", "test");
992 session.set_project_id_meta(" project-1 ");
993 assert_eq!(
994 ProjectContextResolver::session_project_identity(&session),
995 SessionProjectIdentity::Assigned(ProjectId::parse("project-1").unwrap())
996 );
997 }
998
999 #[test]
1000 fn resource_diagnostic_distinguishes_project_and_workspace_layers() {
1001 let directory = tempfile::tempdir().expect("tempdir");
1002 let project_home = directory.path().join("project-home");
1003 let workspace = directory.path().join("workspace");
1004 std::fs::create_dir_all(project_home.join("skills")).expect("project skills");
1005 std::fs::create_dir_all(workspace.join(".bamboo/skills")).expect("workspace skills");
1006 let scope = ProjectResourceScope {
1007 project_id: ProjectId::parse("project-1").expect("project id"),
1008 project_home,
1009 workspace: Some(workspace),
1010 binding_status: WorkspaceBindingStatus::Registered,
1011 resource_revision: 4,
1012 };
1013
1014 let candidates = scope.candidates(ProjectResourceKind::Skills);
1015 assert_eq!(candidates.len(), 2);
1016 assert_eq!(candidates[0].layer, ProjectResourceLayer::Project);
1017 assert_eq!(candidates[1].layer, ProjectResourceLayer::Workspace);
1018 assert!(candidates.iter().all(|candidate| candidate.exists));
1019 }
1020
1021 #[test]
1022 fn workspace_commands_resolve_from_nearest_git_boundary() {
1023 let directory = tempfile::tempdir().expect("tempdir");
1024 let repository = directory.path().join("repo");
1025 let nested = repository.join("nested/path");
1026 std::fs::create_dir_all(repository.join(".git")).expect("git");
1027 std::fs::create_dir_all(&nested).expect("nested");
1028 let scope = ProjectResourceScope {
1029 project_id: ProjectId::parse("project-1").expect("project id"),
1030 project_home: directory.path().join("project-home"),
1031 workspace: Some(nested),
1032 binding_status: WorkspaceBindingStatus::Registered,
1033 resource_revision: 1,
1034 };
1035 assert_eq!(
1036 scope.workspace_commands_dir(),
1037 Some(repository.join(".bamboo/commands"))
1038 );
1039 }
1040
1041 #[test]
1042 fn unassigned_legacy_scope_is_read_only() {
1043 let mut session = Session::new("legacy-session", "legacy");
1044 session.set_workspace_path_meta("/tmp/legacy-workspace");
1045 assert!(ProjectContextResolver::memory_read_scope_for_session(&session).is_some());
1046 assert!(ProjectContextResolver::memory_write_scope_for_session(&session).is_none());
1047 }
1048}