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";
18pub const WORKSPACE_SOURCE_METADATA_KEY: &str = "workspace_source";
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum WorkspaceBindingStatus {
23 Registered,
24 Unregistered,
25}
26
27impl WorkspaceBindingStatus {
28 pub const fn as_str(self) -> &'static str {
29 match self {
30 Self::Registered => "registered",
31 Self::Unregistered => "unregistered",
32 }
33 }
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ProjectDescriptor {
38 pub id: ProjectId,
39 pub name: String,
40 pub project_path: Option<PathBuf>,
42 pub home: PathBuf,
44 pub workspace_bindings: Vec<WorkspaceBinding>,
45 pub resources: ProjectResourceSummary,
46 pub memory_read_roots: ProjectMemoryReadRoots,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct ProjectMemoryReadRoots {
51 pub primary: PathBuf,
52 pub legacy_aliases: Vec<bamboo_memory::memory_store::LegacyProjectMemoryReadRoot>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct ResolvedProjectContext {
57 pub project: ProjectDescriptor,
58 pub workspace: Option<PathBuf>,
59 pub workspace_source: WorkspaceSource,
60 pub binding_status: WorkspaceBindingStatus,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum WorkspaceSource {
66 Explicit,
67 Session,
68 ProjectDefault,
69}
70
71impl WorkspaceSource {
72 pub const fn as_str(self) -> &'static str {
73 match self {
74 Self::Explicit => "explicit",
75 Self::Session => "session",
76 Self::ProjectDefault => "project_default",
77 }
78 }
79}
80
81impl ResolvedProjectContext {
82 pub fn resource_scope(&self) -> ProjectResourceScope {
83 ProjectResourceScope {
84 project_id: self.project.id.clone(),
85 project_home: self.project.home.clone(),
86 workspace: self.workspace.clone(),
87 binding_status: self.binding_status,
88 resource_revision: self.project.resources.resource_revision,
89 }
90 }
91
92 pub fn render_resource_inventory(&self) -> String {
93 let mut entries = self.project.resources.resources.clone();
94 entries.sort_by_key(|entry| entry.kind);
95 let rendered = entries
96 .into_iter()
97 .map(|entry| {
98 format!(
99 "- {:?}: status={}, items={}",
100 entry.kind,
101 if entry.present { "available" } else { "empty" },
102 entry.item_count
103 )
104 })
105 .collect::<Vec<_>>()
106 .join("\n");
107 format!(
108 "Project ID: {}\nResource revision: {}\n{}",
109 self.project.id,
110 self.project.resources.resource_revision,
111 if rendered.is_empty() {
112 "No Project-shared resources are currently advertised.".to_string()
113 } else {
114 rendered
115 }
116 )
117 }
118}
119
120#[derive(Debug, thiserror::Error)]
121pub enum ProjectContextError {
122 #[error("project context source failed: {0}")]
123 Source(String),
124 #[error("project context source returned '{actual}' for requested project '{requested}'")]
125 IdentityMismatch { requested: String, actual: String },
126 #[error(
127 "workspace '{workspace}' belongs to Project '{owner_project_id}', not session Project '{session_project_id}'"
128 )]
129 WorkspaceConflict {
130 workspace: String,
131 owner_project_id: ProjectId,
132 session_project_id: ProjectId,
133 },
134 #[error("workspace '{workspace}' belongs to Project '{owner_project_id}', but the session is Unassigned")]
135 UnassignedWorkspaceConflict {
136 workspace: String,
137 owner_project_id: ProjectId,
138 },
139 #[error("session carries an invalid Project identity '{raw}': {message}")]
140 InvalidProjectIdentity { raw: String, message: String },
141 #[error("assigned Project '{project_id}' is unavailable")]
142 ProjectUnavailable { project_id: ProjectId },
143 #[error("assigned Project '{project_id}' has no configured project_path")]
144 ProjectPathMissing { project_id: ProjectId },
145 #[error("assigned Project '{project_id}' path '{project_path}' is unavailable: {message}")]
146 ProjectPathUnavailable {
147 project_id: ProjectId,
148 project_path: String,
149 message: String,
150 },
151 #[error("workspace '{workspace}' is invalid: {message}")]
152 WorkspaceInvalid { workspace: String, message: String },
153}
154
155#[async_trait]
161pub trait ProjectContextSource: Send + Sync {
162 async fn find_project(
163 &self,
164 project_id: &ProjectId,
165 ) -> Result<Option<ProjectDescriptor>, ProjectContextError>;
166
167 async fn list_projects(&self) -> Result<Vec<ProjectDescriptor>, ProjectContextError> {
168 Ok(Vec::new())
169 }
170
171 async fn find_workspace_owner(
174 &self,
175 _workspace: &Path,
176 ) -> Result<Option<ProjectId>, ProjectContextError> {
177 Ok(None)
178 }
179}
180
181#[derive(Clone)]
182pub struct ProjectContextResolver {
183 source: Arc<dyn ProjectContextSource>,
184 workspace_resolver: bamboo_agent_core::workspace_state::WorkspaceResolver,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub enum SessionProjectIdentity {
189 Unassigned,
190 Assigned(ProjectId),
191 Invalid { raw: String, message: String },
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub enum ProjectMemoryScope {
196 Assigned {
197 project_id: ProjectId,
198 legacy_aliases: Vec<bamboo_memory::memory_store::LegacyProjectMemoryReadRoot>,
199 },
200 LegacyReadOnly(String),
201}
202
203impl ProjectMemoryScope {
204 pub fn key(&self) -> &str {
205 match self {
206 Self::Assigned { project_id, .. } => project_id.as_str(),
207 Self::LegacyReadOnly(project_key) => project_key,
208 }
209 }
210
211 pub fn scoped_store(
212 &self,
213 store: &bamboo_memory::memory_store::MemoryStore,
214 ) -> bamboo_memory::memory_store::MemoryStore {
215 match self {
216 Self::Assigned {
217 project_id,
218 legacy_aliases,
219 } => store.for_project_with_legacy_read_roots(project_id, legacy_aliases.clone()),
220 Self::LegacyReadOnly(_) => store.clone(),
221 }
222 }
223}
224
225impl ProjectContextResolver {
226 pub fn new(source: Arc<dyn ProjectContextSource>) -> Self {
227 Self {
228 source,
229 workspace_resolver:
230 bamboo_agent_core::workspace_state::WorkspaceResolver::from_process_globals(),
231 }
232 }
233
234 pub fn new_with_workspace_resolver(
240 source: Arc<dyn ProjectContextSource>,
241 workspace_resolver: bamboo_agent_core::workspace_state::WorkspaceResolver,
242 ) -> Self {
243 Self {
244 source,
245 workspace_resolver,
246 }
247 }
248
249 pub fn project_id_from_session(session: &Session) -> Option<ProjectId> {
255 match Self::session_project_identity(session) {
256 SessionProjectIdentity::Assigned(project_id) => Some(project_id),
257 SessionProjectIdentity::Invalid { raw, message } => {
258 tracing::warn!(
259 session_id = %session.id,
260 "ignoring invalid persisted Project id '{raw}': {message}"
261 );
262 None
263 }
264 SessionProjectIdentity::Unassigned => None,
265 }
266 }
267
268 pub fn session_project_identity(session: &Session) -> SessionProjectIdentity {
273 let Some(raw) = session.project_id_meta() else {
274 return SessionProjectIdentity::Unassigned;
275 };
276 let normalized = raw.trim();
277 match ProjectId::parse(normalized) {
278 Ok(project_id) => SessionProjectIdentity::Assigned(project_id),
279 Err(error) => SessionProjectIdentity::Invalid {
280 raw,
281 message: error.to_string(),
282 },
283 }
284 }
285
286 pub fn memory_read_scope_for_session(session: &Session) -> Option<String> {
292 Self::memory_read_identity_for_session(session).map(|scope| scope.key().to_string())
293 }
294
295 pub fn memory_read_identity_for_session(session: &Session) -> Option<ProjectMemoryScope> {
296 match Self::session_project_identity(session) {
297 SessionProjectIdentity::Assigned(project_id) => Some(ProjectMemoryScope::Assigned {
298 project_id,
299 legacy_aliases: Vec::new(),
300 }),
301 SessionProjectIdentity::Unassigned => session
302 .workspace_path_meta()
303 .map(PathBuf::from)
304 .or_else(|| {
305 bamboo_tools::tools::workspace_state::get_workspace(session.id.as_str())
306 })
307 .map(|path| {
308 ProjectMemoryScope::LegacyReadOnly(
309 bamboo_memory::memory_store::project_key_from_path(&path),
310 )
311 }),
312 SessionProjectIdentity::Invalid { .. } => None,
313 }
314 }
315
316 pub async fn resolve_memory_read_scope(
317 &self,
318 session: &Session,
319 workspace: Option<&Path>,
320 ) -> Result<Option<ProjectMemoryScope>, ProjectContextError> {
321 match Self::session_project_identity(session) {
322 SessionProjectIdentity::Unassigned => {
323 return Ok(Self::memory_read_identity_for_session(session));
324 }
325 SessionProjectIdentity::Invalid { raw, message } => {
326 return Err(ProjectContextError::InvalidProjectIdentity { raw, message });
327 }
328 SessionProjectIdentity::Assigned(_) => {}
329 }
330 Ok(self
331 .resolve(session, workspace)
332 .await?
333 .map(|context| ProjectMemoryScope::Assigned {
334 project_id: context.project.id,
335 legacy_aliases: context.project.memory_read_roots.legacy_aliases,
336 }))
337 }
338
339 pub async fn list_memory_read_scopes(
340 &self,
341 ) -> Result<Vec<ProjectMemoryScope>, ProjectContextError> {
342 Ok(self
343 .source
344 .list_projects()
345 .await?
346 .into_iter()
347 .map(|project| ProjectMemoryScope::Assigned {
348 project_id: project.id,
349 legacy_aliases: project.memory_read_roots.legacy_aliases,
350 })
351 .collect())
352 }
353
354 pub fn memory_write_scope_for_session(session: &Session) -> Option<String> {
360 match Self::session_project_identity(session) {
361 SessionProjectIdentity::Assigned(project_id) => Some(project_id.into_string()),
362 SessionProjectIdentity::Unassigned | SessionProjectIdentity::Invalid { .. } => None,
363 }
364 }
365
366 pub async fn resolve(
367 &self,
368 session: &Session,
369 workspace: Option<&Path>,
370 ) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
371 let project_id = match Self::session_project_identity(session) {
372 SessionProjectIdentity::Assigned(project_id) => project_id,
373 SessionProjectIdentity::Invalid { raw, message } => {
374 return Err(ProjectContextError::InvalidProjectIdentity { raw, message });
375 }
376 SessionProjectIdentity::Unassigned => {
377 let workspace =
378 self.resolve_workspace_candidate_for_instance(session, workspace)?;
379 if let Some(candidate) = workspace.as_deref() {
380 if let Some(owner_project_id) =
381 self.source.find_workspace_owner(candidate).await?
382 {
383 return Err(ProjectContextError::UnassignedWorkspaceConflict {
384 workspace: candidate.to_string_lossy().into_owned(),
385 owner_project_id,
386 });
387 }
388 }
389 return Ok(None);
390 }
391 };
392 let project = self
393 .source
394 .find_project(&project_id)
395 .await?
396 .ok_or_else(|| ProjectContextError::ProjectUnavailable {
397 project_id: project_id.clone(),
398 })?;
399 if project.id != project_id {
400 return Err(ProjectContextError::IdentityMismatch {
401 requested: project_id.to_string(),
402 actual: project.id.to_string(),
403 });
404 }
405
406 let (workspace, workspace_source) = if let Some(workspace) = workspace {
407 (
408 resolve_existing_workspace_with_resolver(workspace, &self.workspace_resolver)?,
409 WorkspaceSource::Explicit,
410 )
411 } else if session
412 .metadata
413 .get(WORKSPACE_SOURCE_METADATA_KEY)
414 .map(String::as_str)
415 == Some(WorkspaceSource::ProjectDefault.as_str())
416 {
417 (
418 resolve_project_default_workspace(&project, &self.workspace_resolver)?,
419 WorkspaceSource::ProjectDefault,
420 )
421 } else if let Some(workspace) = session.workspace_path_meta() {
422 let persisted_source = session
423 .metadata
424 .get(WORKSPACE_SOURCE_METADATA_KEY)
425 .map(String::as_str);
426 let workspace = resolve_existing_workspace_with_resolver(
427 Path::new(&workspace),
428 &self.workspace_resolver,
429 )?;
430 let source = match persisted_source {
431 Some("explicit") => WorkspaceSource::Explicit,
432 Some("session") => WorkspaceSource::Session,
433 _ => WorkspaceSource::Session,
434 };
435 (workspace, source)
436 } else {
437 (
438 resolve_project_default_workspace(&project, &self.workspace_resolver)?,
439 WorkspaceSource::ProjectDefault,
440 )
441 };
442
443 self.resolve_assigned_project(project, workspace, workspace_source)
444 .await
445 .map(Some)
446 }
447
448 pub fn resolve_workspace_candidate(
454 session: &Session,
455 workspace: Option<&Path>,
456 ) -> Result<Option<PathBuf>, ProjectContextError> {
457 Self::resolve_workspace_candidate_with(
458 &bamboo_agent_core::workspace_state::WorkspaceResolver::from_process_globals(),
459 session,
460 workspace,
461 )
462 }
463
464 fn resolve_workspace_candidate_for_instance(
465 &self,
466 session: &Session,
467 workspace: Option<&Path>,
468 ) -> Result<Option<PathBuf>, ProjectContextError> {
469 Self::resolve_workspace_candidate_with(&self.workspace_resolver, session, workspace)
470 }
471
472 fn resolve_workspace_candidate_with(
473 workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
474 session: &Session,
475 workspace: Option<&Path>,
476 ) -> Result<Option<PathBuf>, ProjectContextError> {
477 let preferred = workspace
478 .map(Path::to_path_buf)
479 .or_else(|| session.workspace_path_meta().map(PathBuf::from));
480 if preferred.is_none()
481 && matches!(
482 Self::session_project_identity(session),
483 SessionProjectIdentity::Assigned(_)
484 )
485 {
486 return Ok(None);
491 }
492 workspace_resolver
493 .resolve_session_workspace_candidate(&session.id, preferred)
494 .map(|candidate| resolve_final_workspace_with(&candidate, workspace_resolver))
495 .transpose()
496 }
497
498 async fn resolve_assigned_project(
499 &self,
500 project: ProjectDescriptor,
501 workspace: PathBuf,
502 workspace_source: WorkspaceSource,
503 ) -> Result<ResolvedProjectContext, ProjectContextError> {
504 let binding_status = match self.source.find_workspace_owner(&workspace).await? {
505 Some(owner) if owner == project.id => WorkspaceBindingStatus::Registered,
506 Some(owner) => {
507 return Err(ProjectContextError::WorkspaceConflict {
508 workspace: workspace.to_string_lossy().into_owned(),
509 owner_project_id: owner,
510 session_project_id: project.id.clone(),
511 });
512 }
513 None if project
514 .project_path
515 .iter()
516 .map(PathBuf::as_path)
517 .chain(
518 project
519 .workspace_bindings
520 .iter()
521 .map(|binding| Path::new(&binding.path)),
522 )
523 .any(|root| path_is_within_binding(root, &workspace)) =>
524 {
525 WorkspaceBindingStatus::Registered
526 }
527 None => WorkspaceBindingStatus::Unregistered,
528 };
529
530 Ok(ResolvedProjectContext {
531 project,
532 workspace: Some(workspace),
533 workspace_source,
534 binding_status,
535 })
536 }
537
538 pub async fn workspace_owner(
539 &self,
540 workspace: &Path,
541 ) -> Result<Option<ProjectId>, ProjectContextError> {
542 self.source.find_workspace_owner(workspace).await
543 }
544
545 pub async fn refresh_session_prompt(
553 &self,
554 session: &mut Session,
555 ) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
556 self.refresh_session_prompt_inner(session, true).await
557 }
558
559 pub async fn refresh_session_prompt_read_only(
567 &self,
568 session: &mut Session,
569 ) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
570 self.refresh_session_prompt_inner(session, false).await
571 }
572
573 async fn refresh_session_prompt_inner(
574 &self,
575 session: &mut Session,
576 sync_runtime_workspace: bool,
577 ) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
578 let resolved = self.resolve(session, None).await?;
579 let workspace = if let Some(context) = resolved.as_ref() {
580 context.workspace.clone()
581 } else {
582 self.resolve_workspace_candidate_for_instance(session, None)?
583 };
584 if let Some(workspace) = workspace.as_deref() {
585 let final_workspace = if sync_runtime_workspace {
586 self.workspace_resolver.publish_resolved_workspace(
587 &session.id,
588 workspace.into(),
589 "project_context_refresh",
590 )
591 } else {
592 workspace.to_path_buf()
593 };
594 session.set_workspace_path_meta(bamboo_config::paths::path_to_display_string(
595 &final_workspace,
596 ));
597 }
598 if let Some(context) = resolved.as_ref() {
599 session.metadata.insert(
600 WORKSPACE_SOURCE_METADATA_KEY.to_string(),
601 context.workspace_source.as_str().to_string(),
602 );
603 } else {
604 session.metadata.remove(WORKSPACE_SOURCE_METADATA_KEY);
605 }
606 let current = session
607 .messages
608 .iter()
609 .find(|message| matches!(message.role, bamboo_agent_core::Role::System))
610 .map(|message| message.content.clone())
611 .or_else(|| session.metadata.get("base_system_prompt").cloned())
612 .unwrap_or_default();
613 let mut updated =
614 crate::runtime::context::upsert_project_prompt_context(¤t, resolved.as_ref());
615
616 if let Some(context) = resolved.as_ref() {
617 session.metadata.insert(
618 PROJECT_RESOURCES_RENDERED_KEY.to_string(),
619 context.render_resource_inventory(),
620 );
621 } else {
622 session.metadata.remove(PROJECT_RESOURCES_RENDERED_KEY);
623 }
624 let workspace_display = workspace
625 .as_deref()
626 .map(bamboo_config::paths::path_to_display_string);
627 updated = crate::runtime::context::upsert_workspace_prompt_context_with_source(
628 &updated,
629 workspace_display.as_deref(),
630 resolved
631 .as_ref()
632 .map(|context| context.binding_status)
633 .unwrap_or(WorkspaceBindingStatus::Unregistered),
634 resolved.as_ref().map(|context| context.workspace_source),
635 );
636
637 if let Some(system_message) = session
638 .messages
639 .iter_mut()
640 .find(|message| matches!(message.role, bamboo_agent_core::Role::System))
641 {
642 system_message.content = updated;
643 } else if !updated.trim().is_empty() {
644 session
645 .messages
646 .insert(0, bamboo_agent_core::Message::system(updated));
647 }
648 crate::runner::refresh_prompt_snapshot(session);
649
650 Ok(resolved)
651 }
652}
653
654fn resolve_final_workspace_with(
655 workspace: &Path,
656 workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
657) -> Result<PathBuf, ProjectContextError> {
658 if workspace.exists() && !workspace.is_dir() {
659 return Err(ProjectContextError::WorkspaceInvalid {
660 workspace: workspace.to_string_lossy().into_owned(),
661 message: "path is not a directory".to_string(),
662 });
663 }
664 let canonical = std::fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
665 let final_workspace = workspace_resolver.preview_workspace_path(canonical);
666 if final_workspace.exists() && !final_workspace.is_dir() {
667 return Err(ProjectContextError::WorkspaceInvalid {
668 workspace: final_workspace.to_string_lossy().into_owned(),
669 message: "resolved path is not a directory".to_string(),
670 });
671 }
672 Ok(std::fs::canonicalize(&final_workspace).unwrap_or(final_workspace))
673}
674
675pub fn resolve_existing_workspace_with_resolver(
681 workspace: &Path,
682 workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
683) -> Result<PathBuf, ProjectContextError> {
684 if !workspace.exists() {
685 return Err(ProjectContextError::WorkspaceInvalid {
686 workspace: workspace.to_string_lossy().into_owned(),
687 message: "path does not exist".to_string(),
688 });
689 }
690 resolve_final_workspace_with(workspace, workspace_resolver)
691}
692
693fn resolve_project_default_workspace(
694 project: &ProjectDescriptor,
695 workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
696) -> Result<PathBuf, ProjectContextError> {
697 let project_path =
698 project
699 .project_path
700 .as_deref()
701 .ok_or_else(|| ProjectContextError::ProjectPathMissing {
702 project_id: project.id.clone(),
703 })?;
704 let display = project_path.to_string_lossy().into_owned();
705 if !project_path.exists() {
706 return Err(ProjectContextError::ProjectPathUnavailable {
707 project_id: project.id.clone(),
708 project_path: display,
709 message: "path does not exist".to_string(),
710 });
711 }
712 let metadata = std::fs::symlink_metadata(project_path).map_err(|error| {
713 ProjectContextError::ProjectPathUnavailable {
714 project_id: project.id.clone(),
715 project_path: display.clone(),
716 message: format!("path metadata is unavailable: {error}"),
717 }
718 })?;
719 if metadata.file_type().is_symlink() || !metadata.is_dir() {
720 return Err(ProjectContextError::ProjectPathUnavailable {
721 project_id: project.id.clone(),
722 project_path: display,
723 message: "configured path is no longer a plain directory".to_string(),
724 });
725 }
726 let canonical = std::fs::canonicalize(project_path).map_err(|error| {
727 ProjectContextError::ProjectPathUnavailable {
728 project_id: project.id.clone(),
729 project_path: display.clone(),
730 message: format!("path could not be canonicalized: {error}"),
731 }
732 })?;
733 if canonical != project_path {
734 return Err(ProjectContextError::ProjectPathUnavailable {
735 project_id: project.id.clone(),
736 project_path: display,
737 message: "configured path no longer resolves to its registered canonical directory"
738 .to_string(),
739 });
740 }
741 let final_workspace = workspace_resolver.preview_workspace_path(canonical.clone());
742 let final_workspace = std::fs::canonicalize(&final_workspace).map_err(|error| {
743 ProjectContextError::ProjectPathUnavailable {
744 project_id: project.id.clone(),
745 project_path: display.clone(),
746 message: format!("confinement target is unavailable: {error}"),
747 }
748 })?;
749 if final_workspace != canonical {
750 return Err(ProjectContextError::ProjectPathUnavailable {
751 project_id: project.id.clone(),
752 project_path: display,
753 message: format!(
754 "workspace confinement redirected the Project path to '{}'",
755 final_workspace.display()
756 ),
757 });
758 }
759 Ok(canonical)
760}
761
762fn path_is_within_binding(binding: &Path, candidate: &Path) -> bool {
763 let Ok(metadata) = std::fs::symlink_metadata(binding) else {
764 return false;
765 };
766 if metadata.file_type().is_symlink() || !metadata.is_dir() {
767 return false;
768 }
769 let Ok(canonical_binding) = std::fs::canonicalize(binding) else {
770 return false;
771 };
772 if canonical_binding != binding {
773 return false;
774 }
775 candidate == binding || candidate.starts_with(binding)
776}
777
778#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
779#[serde(rename_all = "snake_case")]
780pub enum ProjectResourceLayer {
781 Project,
782 Workspace,
783}
784
785#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
786pub struct ProjectResourceCandidate {
787 pub kind: ProjectResourceKind,
788 pub layer: ProjectResourceLayer,
789 pub path: PathBuf,
790 pub exists: bool,
791}
792
793#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
794pub struct ProjectResourceDiagnostic {
795 pub project_id: ProjectId,
796 pub resource_revision: u64,
797 pub workspace_binding_status: WorkspaceBindingStatus,
798 pub candidates: Vec<ProjectResourceCandidate>,
799}
800
801#[derive(Debug, Clone, PartialEq, Eq)]
803pub struct ProjectResourceScope {
804 pub project_id: ProjectId,
805 pub project_home: PathBuf,
806 pub workspace: Option<PathBuf>,
807 pub binding_status: WorkspaceBindingStatus,
808 pub resource_revision: u64,
809}
810
811impl ProjectResourceScope {
812 pub fn project_memory_root(&self) -> PathBuf {
813 self.project_home.join("memory").join("v1")
814 }
815
816 pub fn project_skills_dir(&self) -> PathBuf {
817 self.project_home.join("skills")
818 }
819
820 pub fn project_mode_skills_dir(&self, mode: &str) -> PathBuf {
821 self.project_home.join(format!("skills-{mode}"))
822 }
823
824 pub fn workspace_skills_dir(&self) -> Option<PathBuf> {
825 self.workspace
826 .as_ref()
827 .map(|workspace| workspace.join(".bamboo").join("skills"))
828 }
829
830 pub fn workspace_mode_skills_dir(&self, mode: &str) -> Option<PathBuf> {
831 self.workspace
832 .as_ref()
833 .map(|workspace| workspace.join(".bamboo").join(format!("skills-{mode}")))
834 }
835
836 pub fn project_commands_dir(&self) -> PathBuf {
837 self.project_home.join("commands")
838 }
839
840 pub fn workspace_commands_dir(&self) -> Option<PathBuf> {
841 let workspace = self.workspace.as_deref()?;
842 let boundary = nearest_git_boundary(workspace).unwrap_or_else(|| workspace.to_path_buf());
843 Some(boundary.join(".bamboo").join("commands"))
844 }
845
846 pub fn candidates(&self, kind: ProjectResourceKind) -> Vec<ProjectResourceCandidate> {
850 let mut paths = match kind {
851 ProjectResourceKind::Settings => vec![(
852 ProjectResourceLayer::Project,
853 self.project_home.join("settings.json"),
854 )],
855 ProjectResourceKind::Memory => {
856 vec![(ProjectResourceLayer::Project, self.project_memory_root())]
857 }
858 ProjectResourceKind::Skills => {
859 let mut values = vec![(ProjectResourceLayer::Project, self.project_skills_dir())];
860 if let Some(path) = self.workspace_skills_dir() {
861 values.push((ProjectResourceLayer::Workspace, path));
862 }
863 values
864 }
865 ProjectResourceKind::Commands => {
866 let mut values = vec![(ProjectResourceLayer::Project, self.project_commands_dir())];
867 if let Some(path) = self.workspace_commands_dir() {
868 values.push((ProjectResourceLayer::Workspace, path));
869 }
870 values
871 }
872 ProjectResourceKind::Artifacts => vec![(
873 ProjectResourceLayer::Project,
874 self.project_home.join("artifacts"),
875 )],
876 ProjectResourceKind::State => vec![(
877 ProjectResourceLayer::Project,
878 self.project_home.join("state"),
879 )],
880 };
881
882 paths
883 .drain(..)
884 .map(|(layer, path)| ProjectResourceCandidate {
885 kind,
886 layer,
887 exists: path.exists(),
888 path,
889 })
890 .collect()
891 }
892
893 pub fn diagnostic(&self) -> ProjectResourceDiagnostic {
894 let mut candidates = Vec::new();
895 for kind in [
896 ProjectResourceKind::Settings,
897 ProjectResourceKind::Memory,
898 ProjectResourceKind::Skills,
899 ProjectResourceKind::Commands,
900 ProjectResourceKind::Artifacts,
901 ProjectResourceKind::State,
902 ] {
903 candidates.extend(self.candidates(kind));
904 }
905 ProjectResourceDiagnostic {
906 project_id: self.project_id.clone(),
907 resource_revision: self.resource_revision,
908 workspace_binding_status: self.binding_status,
909 candidates,
910 }
911 }
912}
913
914fn nearest_git_boundary(start: &Path) -> Option<PathBuf> {
915 start
916 .ancestors()
917 .find(|candidate| {
918 let git = candidate.join(".git");
919 git.is_dir() || git.is_file()
920 })
921 .map(Path::to_path_buf)
922}
923
924#[cfg(test)]
925mod tests {
926 use super::*;
927
928 struct StaticSource(ProjectDescriptor);
929
930 #[async_trait]
931 impl ProjectContextSource for StaticSource {
932 async fn find_project(
933 &self,
934 project_id: &ProjectId,
935 ) -> Result<Option<ProjectDescriptor>, ProjectContextError> {
936 Ok((&self.0.id == project_id).then(|| self.0.clone()))
937 }
938 }
939
940 struct OwnedWorkspaceSource {
941 descriptor: ProjectDescriptor,
942 owner: ProjectId,
943 }
944
945 #[async_trait]
946 impl ProjectContextSource for OwnedWorkspaceSource {
947 async fn find_project(
948 &self,
949 project_id: &ProjectId,
950 ) -> Result<Option<ProjectDescriptor>, ProjectContextError> {
951 Ok((&self.descriptor.id == project_id).then(|| self.descriptor.clone()))
952 }
953
954 async fn find_workspace_owner(
955 &self,
956 _workspace: &Path,
957 ) -> Result<Option<ProjectId>, ProjectContextError> {
958 Ok(Some(self.owner.clone()))
959 }
960 }
961
962 #[tokio::test]
963 async fn workspace_changes_do_not_change_project_identity_or_home() {
964 let directory = tempfile::tempdir().expect("tempdir");
965 let main = directory.path().join("main");
966 let worktree = directory.path().join("worktree");
967 std::fs::create_dir_all(&main).expect("main");
968 std::fs::create_dir_all(&worktree).expect("worktree");
969 let main = main.canonicalize().expect("canonical main");
970 let worktree = worktree.canonicalize().expect("canonical worktree");
971 let project_id = ProjectId::parse("01JPROJECT00000000000000000").expect("project id");
972 let descriptor = ProjectDescriptor {
973 id: project_id.clone(),
974 name: "Zenith".to_string(),
975 project_path: Some(main.clone()),
976 home: directory
977 .path()
978 .join("projects/01JPROJECT00000000000000000"),
979 workspace_bindings: vec![
980 WorkspaceBinding {
981 path: main.to_string_lossy().to_string(),
982 label: Some("main".to_string()),
983 git_common_dir: None,
984 },
985 WorkspaceBinding {
986 path: worktree.to_string_lossy().to_string(),
987 label: Some("worktree".to_string()),
988 git_common_dir: None,
989 },
990 ],
991 resources: bamboo_domain::ProjectResourceSummary {
992 project_id: project_id.clone(),
993 resource_revision: 7,
994 resources: Vec::new(),
995 },
996 memory_read_roots: ProjectMemoryReadRoots {
997 primary: directory
998 .path()
999 .join("projects/01JPROJECT00000000000000000/memory/v1"),
1000 legacy_aliases: Vec::new(),
1001 },
1002 };
1003 let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1004 let mut session = Session::new("session-1", "test");
1005 session.set_project_id_meta(project_id.to_string());
1006
1007 let first = resolver
1008 .resolve(&session, Some(&main))
1009 .await
1010 .expect("resolve")
1011 .expect("assigned");
1012 let second = resolver
1013 .resolve(&session, Some(&worktree))
1014 .await
1015 .expect("resolve")
1016 .expect("assigned");
1017 assert_eq!(first.project.id, second.project.id);
1018 assert_eq!(first.project.home, second.project.home);
1019 assert_eq!(second.binding_status, WorkspaceBindingStatus::Registered);
1020 }
1021
1022 #[tokio::test]
1023 async fn assigned_session_uses_project_path_before_global_or_temp_fallback() {
1024 let directory = tempfile::tempdir().expect("tempdir");
1025 let project_path = directory.path().join("project");
1026 let foreign_default = directory.path().join("foreign-default");
1027 let workspace_root = directory.path().join("session-workspaces");
1028 std::fs::create_dir_all(&project_path).expect("project path");
1029 std::fs::create_dir_all(&foreign_default).expect("foreign default");
1030 std::fs::create_dir_all(&workspace_root).expect("workspace root");
1031 let project_id = ProjectId::parse("project-default").expect("Project id");
1032 let descriptor = ProjectDescriptor {
1033 id: project_id.clone(),
1034 name: "Project Default".to_string(),
1035 project_path: Some(project_path.canonicalize().unwrap()),
1036 home: directory.path().join("projects/project-default"),
1037 workspace_bindings: Vec::new(),
1038 resources: bamboo_domain::ProjectResourceSummary {
1039 project_id: project_id.clone(),
1040 resource_revision: 1,
1041 resources: Vec::new(),
1042 },
1043 memory_read_roots: ProjectMemoryReadRoots {
1044 primary: directory.path().join("projects/project-default/memory/v1"),
1045 legacy_aliases: Vec::new(),
1046 },
1047 };
1048 let resolver = ProjectContextResolver::new_with_workspace_resolver(
1049 Arc::new(StaticSource(descriptor)),
1050 bamboo_agent_core::workspace_state::WorkspaceResolver::new(
1051 {
1052 let foreign_default = foreign_default.clone();
1053 move || Some(foreign_default.clone())
1054 },
1055 {
1056 let workspace_root = workspace_root.clone();
1057 move || bamboo_agent_core::workspace_state::WorkspaceRootConfig {
1058 root: workspace_root.clone(),
1059 confine: false,
1060 }
1061 },
1062 ),
1063 );
1064 let mut session = Session::new("project-default-session", "test");
1065 session.set_project_id_meta(project_id.to_string());
1066 session
1067 .messages
1068 .insert(0, bamboo_agent_core::Message::system("base"));
1069
1070 let resolved = resolver
1071 .refresh_session_prompt_read_only(&mut session)
1072 .await
1073 .expect("Project default must resolve")
1074 .expect("assigned Project");
1075 assert_eq!(
1076 resolved.workspace.as_deref(),
1077 Some(project_path.canonicalize().unwrap().as_path())
1078 );
1079 assert_eq!(resolved.workspace_source, WorkspaceSource::ProjectDefault);
1080 assert_eq!(
1081 session.workspace_path_meta().as_deref(),
1082 Some(
1083 bamboo_config::paths::path_to_display_string(&project_path.canonicalize().unwrap())
1084 .as_str()
1085 )
1086 );
1087 let prompt = &session.messages[0].content;
1088 assert_eq!(prompt.matches("Project path:").count(), 1);
1089 assert_eq!(prompt.matches("Project home (Bamboo data):").count(), 1);
1090 assert_eq!(prompt.matches("Workspace path:").count(), 1);
1091 assert_eq!(
1092 prompt.matches("Workspace source: project_default").count(),
1093 1
1094 );
1095 assert!(!prompt.contains(foreign_default.to_string_lossy().as_ref()));
1096 assert!(!workspace_root.join(&session.id).exists());
1097
1098 let moved_project_path = directory.path().join("project-moved");
1099 std::fs::create_dir_all(&moved_project_path).expect("moved Project path");
1100 let moved_descriptor = ProjectDescriptor {
1101 id: project_id,
1102 name: "Project Default".to_string(),
1103 project_path: Some(moved_project_path.canonicalize().unwrap()),
1104 home: directory.path().join("projects/project-default"),
1105 workspace_bindings: Vec::new(),
1106 resources: bamboo_domain::ProjectResourceSummary {
1107 project_id: ProjectId::parse("project-default").unwrap(),
1108 resource_revision: 2,
1109 resources: Vec::new(),
1110 },
1111 memory_read_roots: ProjectMemoryReadRoots {
1112 primary: directory.path().join("projects/project-default/memory/v1"),
1113 legacy_aliases: Vec::new(),
1114 },
1115 };
1116 let moved_resolver = ProjectContextResolver::new(Arc::new(StaticSource(moved_descriptor)));
1117 let moved = moved_resolver
1118 .resolve(&session, None)
1119 .await
1120 .expect("moved Project path")
1121 .expect("assigned Project");
1122 assert_eq!(
1123 moved.workspace.as_deref(),
1124 Some(moved_project_path.canonicalize().unwrap().as_path())
1125 );
1126 assert_eq!(moved.workspace_source, WorkspaceSource::ProjectDefault);
1127 }
1128
1129 #[tokio::test]
1130 async fn legacy_persisted_workspace_equal_to_project_path_remains_session_owned_after_path_cas()
1131 {
1132 let directory = tempfile::tempdir().expect("tempdir");
1133 let project_path = directory.path().join("project");
1134 let moved_project_path = directory.path().join("project-moved");
1135 std::fs::create_dir_all(&project_path).expect("Project path");
1136 std::fs::create_dir_all(&moved_project_path).expect("moved Project path");
1137 let project_path = project_path.canonicalize().expect("canonical Project path");
1138 let moved_project_path = moved_project_path
1139 .canonicalize()
1140 .expect("canonical moved Project path");
1141 let project_id = ProjectId::parse("legacy-persisted-workspace").expect("Project id");
1142 let descriptor = ProjectDescriptor {
1143 id: project_id.clone(),
1144 name: "Legacy persisted workspace".to_string(),
1145 project_path: Some(project_path.clone()),
1146 home: directory.path().join("projects/legacy-persisted-workspace"),
1147 workspace_bindings: Vec::new(),
1148 resources: bamboo_domain::ProjectResourceSummary {
1149 project_id: project_id.clone(),
1150 resource_revision: 1,
1151 resources: Vec::new(),
1152 },
1153 memory_read_roots: ProjectMemoryReadRoots {
1154 primary: directory
1155 .path()
1156 .join("projects/legacy-persisted-workspace/memory/v1"),
1157 legacy_aliases: Vec::new(),
1158 },
1159 };
1160 let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1161 let mut session = Session::new("legacy-persisted-workspace", "test");
1162 session.set_project_id_meta(project_id.to_string());
1163 session
1164 .set_workspace_path_meta(bamboo_config::paths::path_to_display_string(&project_path));
1165
1166 let initial = resolver
1167 .refresh_session_prompt_read_only(&mut session)
1168 .await
1169 .expect("legacy persisted workspace must resolve")
1170 .expect("assigned Project");
1171 assert_eq!(initial.workspace.as_deref(), Some(project_path.as_path()));
1172 assert_eq!(initial.workspace_source, WorkspaceSource::Session);
1173 assert_eq!(
1174 session
1175 .metadata
1176 .get(WORKSPACE_SOURCE_METADATA_KEY)
1177 .map(String::as_str),
1178 Some("session")
1179 );
1180
1181 let moved_descriptor = ProjectDescriptor {
1182 id: project_id.clone(),
1183 name: "Legacy persisted workspace".to_string(),
1184 project_path: Some(moved_project_path),
1185 home: directory.path().join("projects/legacy-persisted-workspace"),
1186 workspace_bindings: Vec::new(),
1187 resources: bamboo_domain::ProjectResourceSummary {
1188 project_id,
1189 resource_revision: 2,
1190 resources: Vec::new(),
1191 },
1192 memory_read_roots: ProjectMemoryReadRoots {
1193 primary: directory
1194 .path()
1195 .join("projects/legacy-persisted-workspace/memory/v1"),
1196 legacy_aliases: Vec::new(),
1197 },
1198 };
1199 let moved_resolver = ProjectContextResolver::new(Arc::new(StaticSource(moved_descriptor)));
1200 let after_cas = moved_resolver
1201 .resolve(&session, None)
1202 .await
1203 .expect("persisted workspace must remain authoritative")
1204 .expect("assigned Project");
1205 assert_eq!(after_cas.workspace.as_deref(), Some(project_path.as_path()));
1206 assert_eq!(after_cas.workspace_source, WorkspaceSource::Session);
1207 }
1208
1209 #[cfg(unix)]
1210 #[tokio::test]
1211 async fn project_default_rejects_configured_path_replaced_by_symlink() {
1212 use std::os::unix::fs::symlink;
1213
1214 let directory = tempfile::tempdir().expect("tempdir");
1215 let project_path = directory.path().join("project");
1216 let replacement = directory.path().join("replacement");
1217 std::fs::create_dir_all(&project_path).expect("Project path");
1218 std::fs::create_dir_all(&replacement).expect("replacement");
1219 let configured_path = project_path.canonicalize().expect("canonical Project path");
1220 let project_id = ProjectId::parse("symlink-replaced-project").expect("Project id");
1221 let descriptor = ProjectDescriptor {
1222 id: project_id.clone(),
1223 name: "Symlink replacement".to_string(),
1224 project_path: Some(configured_path),
1225 home: directory.path().join("projects/symlink-replaced-project"),
1226 workspace_bindings: Vec::new(),
1227 resources: bamboo_domain::ProjectResourceSummary {
1228 project_id: project_id.clone(),
1229 resource_revision: 1,
1230 resources: Vec::new(),
1231 },
1232 memory_read_roots: ProjectMemoryReadRoots {
1233 primary: directory
1234 .path()
1235 .join("projects/symlink-replaced-project/memory/v1"),
1236 legacy_aliases: Vec::new(),
1237 },
1238 };
1239 let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1240 let mut session = Session::new("symlink-replaced-project", "test");
1241 session.set_project_id_meta(project_id.to_string());
1242
1243 std::fs::remove_dir(&project_path).expect("remove configured Project path");
1244 symlink(&replacement, &project_path).expect("replace Project path with symlink");
1245
1246 assert!(matches!(
1247 resolver.resolve(&session, None).await,
1248 Err(ProjectContextError::ProjectPathUnavailable { .. })
1249 ));
1250 assert!(session.workspace_path_meta().is_none());
1251 }
1252
1253 #[cfg(unix)]
1254 #[tokio::test]
1255 async fn explicit_workspace_is_not_registered_through_a_replaced_project_path_symlink() {
1256 use std::os::unix::fs::symlink;
1257
1258 let directory = tempfile::tempdir().expect("tempdir");
1259 let project_path = directory.path().join("project");
1260 let replacement = directory.path().join("replacement");
1261 let explicit_workspace = replacement.join("explicit");
1262 std::fs::create_dir_all(&project_path).expect("Project path");
1263 std::fs::create_dir_all(&explicit_workspace).expect("explicit workspace");
1264 let configured_path = project_path.canonicalize().expect("canonical Project path");
1265 let project_id = ProjectId::parse("symlink-explicit-project").expect("Project id");
1266 let descriptor = ProjectDescriptor {
1267 id: project_id.clone(),
1268 name: "Symlink explicit workspace".to_string(),
1269 project_path: Some(configured_path),
1270 home: directory.path().join("projects/symlink-explicit-project"),
1271 workspace_bindings: Vec::new(),
1272 resources: bamboo_domain::ProjectResourceSummary {
1273 project_id: project_id.clone(),
1274 resource_revision: 1,
1275 resources: Vec::new(),
1276 },
1277 memory_read_roots: ProjectMemoryReadRoots {
1278 primary: directory
1279 .path()
1280 .join("projects/symlink-explicit-project/memory/v1"),
1281 legacy_aliases: Vec::new(),
1282 },
1283 };
1284 let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1285 let mut session = Session::new("symlink-explicit-project", "test");
1286 session.set_project_id_meta(project_id.to_string());
1287
1288 std::fs::remove_dir(&project_path).expect("remove configured Project path");
1289 symlink(&replacement, &project_path).expect("replace Project path with symlink");
1290
1291 let resolved = resolver
1292 .resolve(&session, Some(&explicit_workspace))
1293 .await
1294 .expect("explicit workspace remains independently resolvable")
1295 .expect("assigned Project");
1296 assert_eq!(
1297 resolved.workspace.as_deref(),
1298 Some(explicit_workspace.canonicalize().unwrap().as_path())
1299 );
1300 assert_eq!(
1301 resolved.binding_status,
1302 WorkspaceBindingStatus::Unregistered
1303 );
1304 assert_eq!(resolved.workspace_source, WorkspaceSource::Explicit);
1305 }
1306
1307 #[tokio::test]
1308 async fn missing_or_unavailable_project_path_fails_closed() {
1309 let directory = tempfile::tempdir().expect("tempdir");
1310 let project_id = ProjectId::parse("unconfigured-project").expect("Project id");
1311 let descriptor = ProjectDescriptor {
1312 id: project_id.clone(),
1313 name: "Unconfigured".to_string(),
1314 project_path: None,
1315 home: directory.path().join("projects/unconfigured-project"),
1316 workspace_bindings: Vec::new(),
1317 resources: bamboo_domain::ProjectResourceSummary {
1318 project_id: project_id.clone(),
1319 resource_revision: 1,
1320 resources: Vec::new(),
1321 },
1322 memory_read_roots: ProjectMemoryReadRoots {
1323 primary: directory
1324 .path()
1325 .join("projects/unconfigured-project/memory/v1"),
1326 legacy_aliases: Vec::new(),
1327 },
1328 };
1329 let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1330 let mut session = Session::new("unconfigured", "test");
1331 session.set_project_id_meta(project_id.to_string());
1332 assert!(matches!(
1333 resolver.resolve(&session, None).await,
1334 Err(ProjectContextError::ProjectPathMissing { .. })
1335 ));
1336
1337 let missing = directory.path().join("moved-away");
1338 let descriptor = ProjectDescriptor {
1339 id: project_id.clone(),
1340 name: "Unavailable".to_string(),
1341 project_path: Some(missing.clone()),
1342 home: directory.path().join("projects/unconfigured-project"),
1343 workspace_bindings: Vec::new(),
1344 resources: bamboo_domain::ProjectResourceSummary {
1345 project_id: project_id.clone(),
1346 resource_revision: 1,
1347 resources: Vec::new(),
1348 },
1349 memory_read_roots: ProjectMemoryReadRoots {
1350 primary: directory
1351 .path()
1352 .join("projects/unconfigured-project/memory/v1"),
1353 legacy_aliases: Vec::new(),
1354 },
1355 };
1356 let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1357 assert!(matches!(
1358 resolver.resolve(&session, None).await,
1359 Err(ProjectContextError::ProjectPathUnavailable {
1360 project_path,
1361 ..
1362 }) if project_path == missing.to_string_lossy()
1363 ));
1364 assert!(session.workspace_path_meta().is_none());
1365 }
1366
1367 #[tokio::test]
1368 async fn prompt_refresh_removes_stale_project_and_updates_unassigned_workspace() {
1369 let directory = tempfile::tempdir().expect("tempdir");
1370 let workspace = directory.path().join("workspace");
1371 std::fs::create_dir_all(&workspace).expect("workspace");
1372 let workspace = workspace.canonicalize().expect("canonical workspace");
1373 let project_id = ProjectId::parse("project-prompt-refresh").expect("Project id");
1374 let descriptor = ProjectDescriptor {
1375 id: project_id.clone(),
1376 name: "Prompt Project".to_string(),
1377 project_path: Some(workspace.clone()),
1378 home: directory.path().join("projects/project-prompt-refresh"),
1379 workspace_bindings: vec![WorkspaceBinding {
1380 path: workspace.to_string_lossy().into_owned(),
1381 label: None,
1382 git_common_dir: None,
1383 }],
1384 resources: bamboo_domain::ProjectResourceSummary {
1385 project_id: project_id.clone(),
1386 resource_revision: 1,
1387 resources: Vec::new(),
1388 },
1389 memory_read_roots: ProjectMemoryReadRoots {
1390 primary: directory
1391 .path()
1392 .join("projects/project-prompt-refresh/memory/v1"),
1393 legacy_aliases: Vec::new(),
1394 },
1395 };
1396 let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1397 let mut session = Session::new("prompt-project-switch", "test");
1398 session
1399 .messages
1400 .insert(0, bamboo_agent_core::Message::system("base"));
1401 session.set_project_id_meta(project_id.to_string());
1402 session.set_workspace_path_meta(workspace.to_string_lossy().into_owned());
1403
1404 resolver
1405 .refresh_session_prompt(&mut session)
1406 .await
1407 .expect("assigned refresh");
1408 let assigned = &session.messages[0].content;
1409 assert_eq!(assigned.matches("BAMBOO_PROJECT_CONTEXT_START").count(), 1);
1410 assert!(assigned.contains("Binding status: registered"));
1411
1412 session.clear_project_id_meta();
1413 resolver
1414 .refresh_session_prompt(&mut session)
1415 .await
1416 .expect("unassigned refresh");
1417 let unassigned = &session.messages[0].content;
1418 assert_eq!(
1419 unassigned.matches("BAMBOO_PROJECT_CONTEXT_START").count(),
1420 0
1421 );
1422 assert_eq!(
1423 unassigned.matches("BAMBOO_WORKSPACE_CONTEXT_START").count(),
1424 1
1425 );
1426 assert!(unassigned.contains("Binding status: unregistered"));
1427 assert!(!session
1428 .metadata
1429 .contains_key(PROJECT_RESOURCES_RENDERED_KEY));
1430 }
1431
1432 #[tokio::test]
1433 async fn workspace_owned_by_another_project_fails_closed() {
1434 let directory = tempfile::tempdir().expect("tempdir");
1435 let workspace = directory.path().join("workspace");
1436 std::fs::create_dir_all(&workspace).expect("workspace");
1437 let project_id = ProjectId::parse("project-a").expect("project id");
1438 let owner = ProjectId::parse("project-b").expect("owner id");
1439 let descriptor = ProjectDescriptor {
1440 id: project_id.clone(),
1441 name: "Project A".to_string(),
1442 project_path: Some(workspace.clone()),
1443 home: directory.path().join("projects/project-a"),
1444 workspace_bindings: vec![WorkspaceBinding {
1445 path: workspace.to_string_lossy().into_owned(),
1446 label: None,
1447 git_common_dir: None,
1448 }],
1449 resources: bamboo_domain::ProjectResourceSummary {
1450 project_id: project_id.clone(),
1451 resource_revision: 1,
1452 resources: Vec::new(),
1453 },
1454 memory_read_roots: ProjectMemoryReadRoots {
1455 primary: directory.path().join("projects/project-a/memory/v1"),
1456 legacy_aliases: Vec::new(),
1457 },
1458 };
1459 let resolver = ProjectContextResolver::new(Arc::new(OwnedWorkspaceSource {
1460 descriptor,
1461 owner: owner.clone(),
1462 }));
1463 let mut session = Session::new("session-conflict", "test");
1464 session.set_project_id_meta(project_id.to_string());
1465
1466 let error = resolver
1467 .resolve(&session, Some(&workspace))
1468 .await
1469 .expect_err("cross-Project workspace must fail closed");
1470 assert!(matches!(
1471 error,
1472 ProjectContextError::WorkspaceConflict {
1473 owner_project_id,
1474 session_project_id,
1475 ..
1476 } if owner_project_id == owner && session_project_id == project_id
1477 ));
1478
1479 let safe_workspace = directory.path().join("safe");
1480 std::fs::create_dir_all(&safe_workspace).expect("safe workspace");
1481 let safe_canonical = safe_workspace.canonicalize().expect("canonical safe");
1482 bamboo_tools::tools::workspace_state::set_workspace(&session.id, safe_canonical.clone());
1483 session.set_workspace_path_meta(workspace.to_string_lossy().into_owned());
1484 let error = resolver
1485 .refresh_session_prompt(&mut session)
1486 .await
1487 .expect_err("refresh must validate before publishing workspace");
1488 assert!(matches!(
1489 error,
1490 ProjectContextError::WorkspaceConflict { .. }
1491 ));
1492 assert_eq!(
1493 bamboo_tools::tools::workspace_state::get_workspace(&session.id).as_deref(),
1494 Some(safe_canonical.as_path()),
1495 "a post-preflight ownership change must not publish the rejected workspace"
1496 );
1497 }
1498
1499 #[tokio::test]
1500 async fn malformed_project_identity_never_falls_back_to_legacy_workspace_scope() {
1501 let directory = tempfile::tempdir().expect("tempdir");
1502 let workspace = directory.path().join("workspace");
1503 std::fs::create_dir_all(&workspace).expect("workspace");
1504 let descriptor_id = ProjectId::parse("descriptor").expect("Project id");
1505 let descriptor = ProjectDescriptor {
1506 id: descriptor_id.clone(),
1507 name: "Descriptor".to_string(),
1508 project_path: Some(workspace.clone()),
1509 home: directory.path().join("projects/descriptor"),
1510 workspace_bindings: Vec::new(),
1511 resources: bamboo_domain::ProjectResourceSummary {
1512 project_id: descriptor_id.clone(),
1513 resource_revision: 1,
1514 resources: Vec::new(),
1515 },
1516 memory_read_roots: ProjectMemoryReadRoots {
1517 primary: directory.path().join("projects/descriptor/memory/v1"),
1518 legacy_aliases: Vec::new(),
1519 },
1520 };
1521 let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1522 let mut session = Session::new("malformed", "test");
1523 session.set_project_id_meta("../malformed");
1524 session.set_workspace_path_meta(workspace.to_string_lossy().into_owned());
1525
1526 assert!(ProjectContextResolver::memory_read_identity_for_session(&session).is_none());
1527 assert!(matches!(
1528 resolver.resolve(&session, Some(&workspace)).await,
1529 Err(ProjectContextError::InvalidProjectIdentity { .. })
1530 ));
1531 assert!(matches!(
1532 resolver
1533 .resolve_memory_read_scope(&session, Some(&workspace))
1534 .await,
1535 Err(ProjectContextError::InvalidProjectIdentity { .. })
1536 ));
1537 }
1538
1539 #[tokio::test]
1540 async fn unassigned_session_cannot_resolve_a_project_owned_workspace() {
1541 let directory = tempfile::tempdir().expect("tempdir");
1542 let workspace = directory.path().join("workspace");
1543 std::fs::create_dir_all(&workspace).expect("workspace");
1544 let descriptor_id = ProjectId::parse("descriptor").expect("Project id");
1545 let owner = ProjectId::parse("owner").expect("owner Project id");
1546 let descriptor = ProjectDescriptor {
1547 id: descriptor_id.clone(),
1548 name: "Descriptor".to_string(),
1549 project_path: None,
1550 home: directory.path().join("projects/descriptor"),
1551 workspace_bindings: Vec::new(),
1552 resources: bamboo_domain::ProjectResourceSummary {
1553 project_id: descriptor_id,
1554 resource_revision: 1,
1555 resources: Vec::new(),
1556 },
1557 memory_read_roots: ProjectMemoryReadRoots {
1558 primary: directory.path().join("projects/descriptor/memory/v1"),
1559 legacy_aliases: Vec::new(),
1560 },
1561 };
1562 let resolver = ProjectContextResolver::new(Arc::new(OwnedWorkspaceSource {
1563 descriptor,
1564 owner: owner.clone(),
1565 }));
1566 let session = Session::new("unassigned", "test");
1567
1568 assert!(matches!(
1569 resolver.resolve(&session, Some(&workspace)).await,
1570 Err(ProjectContextError::UnassignedWorkspaceConflict {
1571 owner_project_id,
1572 ..
1573 }) if owner_project_id == owner
1574 ));
1575 }
1576
1577 #[test]
1578 fn project_identity_parser_trims_like_the_storage_index() {
1579 let mut session = Session::new("whitespace", "test");
1580 session.set_project_id_meta(" project-1 ");
1581 assert_eq!(
1582 ProjectContextResolver::session_project_identity(&session),
1583 SessionProjectIdentity::Assigned(ProjectId::parse("project-1").unwrap())
1584 );
1585 }
1586
1587 #[test]
1588 fn resource_diagnostic_distinguishes_project_and_workspace_layers() {
1589 let directory = tempfile::tempdir().expect("tempdir");
1590 let project_home = directory.path().join("project-home");
1591 let workspace = directory.path().join("workspace");
1592 std::fs::create_dir_all(project_home.join("skills")).expect("project skills");
1593 std::fs::create_dir_all(workspace.join(".bamboo/skills")).expect("workspace skills");
1594 let scope = ProjectResourceScope {
1595 project_id: ProjectId::parse("project-1").expect("project id"),
1596 project_home,
1597 workspace: Some(workspace),
1598 binding_status: WorkspaceBindingStatus::Registered,
1599 resource_revision: 4,
1600 };
1601
1602 let candidates = scope.candidates(ProjectResourceKind::Skills);
1603 assert_eq!(candidates.len(), 2);
1604 assert_eq!(candidates[0].layer, ProjectResourceLayer::Project);
1605 assert_eq!(candidates[1].layer, ProjectResourceLayer::Workspace);
1606 assert!(candidates.iter().all(|candidate| candidate.exists));
1607 }
1608
1609 #[test]
1610 fn workspace_commands_resolve_from_nearest_git_boundary() {
1611 let directory = tempfile::tempdir().expect("tempdir");
1612 let repository = directory.path().join("repo");
1613 let nested = repository.join("nested/path");
1614 std::fs::create_dir_all(repository.join(".git")).expect("git");
1615 std::fs::create_dir_all(&nested).expect("nested");
1616 let scope = ProjectResourceScope {
1617 project_id: ProjectId::parse("project-1").expect("project id"),
1618 project_home: directory.path().join("project-home"),
1619 workspace: Some(nested),
1620 binding_status: WorkspaceBindingStatus::Registered,
1621 resource_revision: 1,
1622 };
1623 assert_eq!(
1624 scope.workspace_commands_dir(),
1625 Some(repository.join(".bamboo/commands"))
1626 );
1627 }
1628
1629 #[test]
1630 fn unassigned_legacy_scope_is_read_only() {
1631 let mut session = Session::new("legacy-session", "legacy");
1632 session.set_workspace_path_meta("/tmp/legacy-workspace");
1633 assert!(ProjectContextResolver::memory_read_scope_for_session(&session).is_some());
1634 assert!(ProjectContextResolver::memory_write_scope_for_session(&session).is_none());
1635 }
1636}