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