1use std::path::Path;
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use bamboo_agent_core::{Session, Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
12use bamboo_domain::{ProjectId, ProjectManifest, ProjectStatus, WorkspaceBinding};
13use bamboo_engine::project_context::{
14 ProjectContextResolver, SessionProjectIdentity, WorkspaceSource, WORKSPACE_SOURCE_METADATA_KEY,
15};
16use bamboo_engine::SessionRepository;
17use bamboo_projects::{ProjectStore, ProjectStoreError};
18use serde_json::{json, Value};
19
20#[derive(Debug)]
21enum EffectiveProjectWorkspaceError {
22 Missing,
23 Unavailable(String),
24 WorkspaceInvalid { workspace: String, message: String },
25}
26
27fn canonical_project_path(
28 project: &ProjectManifest,
29 workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
30) -> Result<std::path::PathBuf, EffectiveProjectWorkspaceError> {
31 let Some(configured) = project.project_path.as_deref() else {
32 return Err(EffectiveProjectWorkspaceError::Missing);
33 };
34 let configured = std::path::PathBuf::from(configured);
35 if !configured.is_dir() {
36 return Err(EffectiveProjectWorkspaceError::Unavailable(
37 bamboo_config::paths::path_to_display_string(&configured),
38 ));
39 }
40 let canonical = configured.canonicalize().map_err(|_| {
41 EffectiveProjectWorkspaceError::Unavailable(bamboo_config::paths::path_to_display_string(
42 &configured,
43 ))
44 })?;
45 let confined = workspace_resolver.preview_workspace_path(canonical.clone());
46 let confined = confined.canonicalize().map_err(|_| {
47 EffectiveProjectWorkspaceError::Unavailable(bamboo_config::paths::path_to_display_string(
48 &configured,
49 ))
50 })?;
51 if canonical != configured || confined != canonical {
52 return Err(EffectiveProjectWorkspaceError::Unavailable(
53 bamboo_config::paths::path_to_display_string(&configured),
54 ));
55 }
56 Ok(canonical)
57}
58
59fn effective_project_workspace(
60 session: &Session,
61 project: &ProjectManifest,
62 workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
63) -> Result<(std::path::PathBuf, WorkspaceSource), EffectiveProjectWorkspaceError> {
64 let persisted_source = session
65 .metadata
66 .get(WORKSPACE_SOURCE_METADATA_KEY)
67 .map(String::as_str);
68 if persisted_source == Some(WorkspaceSource::ProjectDefault.as_str()) {
69 return canonical_project_path(project, workspace_resolver)
70 .map(|workspace| (workspace, WorkspaceSource::ProjectDefault));
71 }
72 if let Some(workspace) = session.workspace_path_meta() {
73 let workspace = std::path::PathBuf::from(workspace);
74 let workspace = bamboo_engine::project_context::resolve_existing_workspace_with_resolver(
75 &workspace,
76 workspace_resolver,
77 )
78 .map_err(|error| match error {
79 bamboo_engine::project_context::ProjectContextError::WorkspaceInvalid {
80 workspace,
81 message,
82 } => EffectiveProjectWorkspaceError::WorkspaceInvalid { workspace, message },
83 error => EffectiveProjectWorkspaceError::WorkspaceInvalid {
84 workspace: bamboo_config::paths::path_to_display_string(&workspace),
85 message: error.to_string(),
86 },
87 })?;
88 let source = match persisted_source {
89 Some("explicit") => WorkspaceSource::Explicit,
90 Some("session") => WorkspaceSource::Session,
91 _ => WorkspaceSource::Session,
92 };
93 return Ok((workspace, source));
94 }
95 canonical_project_path(project, workspace_resolver)
96 .map(|workspace| (workspace, WorkspaceSource::ProjectDefault))
97}
98
99fn project_workspace_error(
100 project: &ProjectManifest,
101 error: EffectiveProjectWorkspaceError,
102) -> Value {
103 match error {
104 EffectiveProjectWorkspaceError::Missing => json!({
105 "code": "project_path_missing",
106 "message": "Assigned Project has no configured project_path",
107 "project_id": project.id,
108 }),
109 EffectiveProjectWorkspaceError::Unavailable(project_path) => json!({
110 "code": "project_path_unavailable",
111 "message": "Assigned Project path is unavailable",
112 "project_id": project.id,
113 "project_path": project_path,
114 }),
115 EffectiveProjectWorkspaceError::WorkspaceInvalid { workspace, message } => json!({
116 "code": "workspace_invalid",
117 "message": message,
118 "project_id": project.id,
119 "workspace": workspace,
120 }),
121 }
122}
123
124pub struct ProjectWorkspaceTool {
125 sessions: SessionRepository,
126 projects: Arc<ProjectStore>,
127 workspace_resolver: bamboo_agent_core::workspace_state::WorkspaceResolver,
128}
129
130impl ProjectWorkspaceTool {
131 pub fn new(sessions: SessionRepository, projects: Arc<ProjectStore>) -> Self {
132 Self::new_with_workspace_resolver(
133 sessions,
134 projects,
135 bamboo_agent_core::workspace_state::WorkspaceResolver::from_process_globals(),
136 )
137 }
138
139 pub fn new_with_workspace_resolver(
140 sessions: SessionRepository,
141 projects: Arc<ProjectStore>,
142 workspace_resolver: bamboo_agent_core::workspace_state::WorkspaceResolver,
143 ) -> Self {
144 Self {
145 sessions,
146 projects,
147 workspace_resolver,
148 }
149 }
150
151 async fn session_project(
152 &self,
153 session_id: &str,
154 ) -> Result<(Option<Session>, Option<ProjectManifest>), ToolError> {
155 let Some(session) = self.sessions.try_load(session_id).await.map_err(|error| {
156 ToolError::Execution(format!("failed to load Workspace session: {error}"))
157 })?
158 else {
159 return Ok((None, None));
160 };
161 let Some(raw_project_id) = session.project_id_meta() else {
162 return Ok((Some(session), None));
163 };
164 let project_id = raw_project_id
165 .trim()
166 .parse::<ProjectId>()
167 .map_err(|error| {
168 ToolError::Execution(format!(
169 "session carries an invalid assigned Project identity: {error}"
170 ))
171 })?;
172 match self.projects.get(&project_id) {
173 Ok(project) => Ok((Some(session), Some(project))),
174 Err(error) => Err(ToolError::Execution(format!(
175 "assigned Project is unavailable; Workspace change refused: {error}"
176 ))),
177 }
178 }
179
180 fn response(
181 &self,
182 session_id: Option<&str>,
183 workspace: &Path,
184 project: Option<&ProjectManifest>,
185 binding_status: &str,
186 workspace_source: &str,
187 relocated_from: Option<&Path>,
188 ) -> Value {
189 json!({
190 "session_id": session_id,
191 "project_id": project.map(|value| value.id.to_string()),
192 "project_name": project.map(|value| value.name.clone()),
193 "project_path": project.and_then(|value| value.project_path.clone()),
194 "project_home": project.map(|value| {
195 bamboo_config::paths::path_to_display_string(
196 &self.projects.paths().project_home(&value.id)
197 )
198 }),
199 "workspace": bamboo_config::paths::path_to_display_string(workspace),
200 "workspace_source": workspace_source,
201 "binding_status": binding_status,
202 "relocated_from": relocated_from.map(
203 bamboo_config::paths::path_to_display_string
204 ),
205 })
206 }
207}
208
209#[async_trait]
210impl Tool for ProjectWorkspaceTool {
211 fn name(&self) -> &str {
212 "Workspace"
213 }
214
215 fn description(&self) -> &str {
216 "Get or set the mutable session workspace while preserving Project identity. Returns structured Project/binding diagnostics."
217 }
218
219 fn classify(&self, args: &Value) -> ToolClass {
220 if args
221 .get("path")
222 .and_then(Value::as_str)
223 .is_some_and(|path| !path.trim().is_empty())
224 {
225 ToolClass::MUTATING_SERIAL
226 } else {
227 ToolClass::READONLY_PARALLEL
228 }
229 }
230
231 fn parameters_schema(&self) -> Value {
232 json!({
233 "type": "object",
234 "properties": {
235 "path": {
236 "type": "string",
237 "description": "Workspace directory to set. Omit to inspect."
238 }
239 },
240 "additionalProperties": false
241 })
242 }
243
244 async fn invoke(&self, args: Value, ctx: ToolCtx) -> Result<ToolOutcome, ToolError> {
245 let session_id = ctx.session_id();
246 let (session, mut project) = match session_id {
247 Some(id) => self.session_project(id).await?,
248 None => (None, None),
249 };
250 let requested = args
251 .get("path")
252 .and_then(Value::as_str)
253 .map(str::trim)
254 .filter(|path| !path.is_empty());
255
256 let (workspace, relocated_from, binding_status, workspace_source) = if let Some(requested) =
257 requested
258 {
259 let session_id = session_id.ok_or_else(|| {
260 ToolError::Execution(
261 "Workspace(set) requires a session_id in tool context".to_string(),
262 )
263 })?;
264 let requested_path = Path::new(requested);
265 let requested_path = if requested_path.is_absolute() {
266 requested_path.to_path_buf()
267 } else {
268 let preferred = match (session.as_ref(), project.as_ref()) {
269 (Some(session), Some(project)) => Some(
270 effective_project_workspace(session, project, &self.workspace_resolver)
271 .map_err(|error| {
272 ToolError::Execution(
273 project_workspace_error(project, error).to_string(),
274 )
275 })?
276 .0,
277 ),
278 _ => session
279 .as_ref()
280 .and_then(Session::workspace_path_meta)
281 .map(std::path::PathBuf::from),
282 };
283 let base = if project.is_some() {
284 preferred.ok_or_else(|| {
285 ToolError::Execution(
286 "assigned Project has no configured project_path".to_string(),
287 )
288 })?
289 } else {
290 self.workspace_resolver
291 .resolve_session_workspace_candidate(session_id, preferred)
292 .unwrap_or_else(|| {
293 std::env::current_dir()
294 .unwrap_or_else(|_| std::path::PathBuf::from("."))
295 })
296 };
297 base.join(requested_path)
298 };
299 if !requested_path.exists() {
300 return Ok(completed(
301 false,
302 json!({
303 "code": "workspace_not_found",
304 "message": format!("Path does not exist: {}", requested_path.display())
305 }),
306 ));
307 }
308 if !requested_path.is_dir() {
309 return Ok(completed(
310 false,
311 json!({
312 "code": "workspace_not_directory",
313 "message": format!("Path is not a directory: {}", requested_path.display())
314 }),
315 ));
316 }
317 let canonical = requested_path.canonicalize().map_err(|error| {
318 ToolError::Execution(format!("failed to canonicalize workspace: {error}"))
319 })?;
320 let final_path = self
323 .workspace_resolver
324 .preview_workspace_path(canonical.clone());
325 let final_display = bamboo_config::paths::path_to_display_string(&final_path);
326 let owner = self
327 .projects
328 .find_workspace_owner_for_path(&final_display)
329 .map_err(project_tool_error)?;
330 let _binding_status = match owner {
331 Some(owner) if project.as_ref().map(|value| &value.id) == Some(&owner.id) => {
332 "registered"
333 }
334 Some(owner) => {
335 return Ok(completed(
336 false,
337 json!({
338 "code": "project_workspace_conflict",
339 "message": "The confinement-resolved workspace is registered to another Project",
340 "workspace": final_display,
341 "owner_project_id": owner.id,
342 "session_project_id": project.as_ref().map(|value| value.id.to_string()),
343 "relocated_from": (final_path != canonical).then(|| {
344 bamboo_config::paths::path_to_display_string(&canonical)
345 }),
346 }),
347 ));
348 }
349 None => "unregistered",
350 };
351 let persistence_guard = self.sessions.persistence().acquire_lock(session_id).await;
356 let mut authoritative = self
357 .sessions
358 .storage()
359 .load_session(session_id)
360 .await
361 .map_err(|error| {
362 ToolError::Execution(format!(
363 "failed to reload Workspace session authority: {error}"
364 ))
365 })?
366 .ok_or_else(|| ToolError::Execution("session was not found".to_string()))?;
367 let authoritative_project =
368 match ProjectContextResolver::session_project_identity(&authoritative) {
369 SessionProjectIdentity::Assigned(project_id) => {
370 Some(self.projects.get(&project_id).map_err(project_tool_error)?)
371 }
372 SessionProjectIdentity::Unassigned => None,
373 SessionProjectIdentity::Invalid { raw, message } => {
374 return Err(ToolError::Execution(format!(
375 "session carries an invalid assigned Project identity '{raw}': {message}"
376 )));
377 }
378 };
379 let authoritative_owner = self
380 .projects
381 .find_workspace_owner_for_path(&final_display)
382 .map_err(project_tool_error)?;
383 let authoritative_binding_status = match authoritative_owner {
384 Some(owner)
385 if authoritative_project.as_ref().map(|value| &value.id) == Some(&owner.id) =>
386 {
387 "registered"
388 }
389 Some(owner) => {
390 return Ok(completed(
391 false,
392 json!({
393 "code": "project_workspace_conflict",
394 "message": "The confinement-resolved workspace is registered to another Project",
395 "workspace": final_display,
396 "owner_project_id": owner.id,
397 "session_project_id": authoritative_project
398 .as_ref()
399 .map(|value| value.id.to_string()),
400 "relocated_from": (final_path != canonical).then(|| {
401 bamboo_config::paths::path_to_display_string(&canonical)
402 }),
403 }),
404 ));
405 }
406 None => "unregistered",
407 };
408 authoritative.set_workspace_path_meta(final_display);
409 authoritative.metadata.insert(
410 bamboo_engine::project_context::WORKSPACE_SOURCE_METADATA_KEY.to_string(),
411 bamboo_engine::project_context::WorkspaceSource::Explicit
412 .as_str()
413 .to_string(),
414 );
415 self.sessions
416 .storage()
417 .save_session(&authoritative)
418 .await
419 .map_err(|error| {
420 ToolError::Execution(format!(
421 "failed to persist validated workspace before publication: {error}"
422 ))
423 })?;
424 self.sessions.cache().insert(
425 session_id.to_string(),
426 Arc::new(parking_lot::RwLock::new(authoritative.clone())),
427 );
428 drop(persistence_guard);
429 project = authoritative_project;
430 let stored = self.workspace_resolver.publish_resolved_workspace(
431 session_id,
432 final_path.clone(),
433 bamboo_engine::project_context::WorkspaceSource::Explicit.as_str(),
434 );
435 let relocated = (stored != canonical).then_some(canonical);
436 (
437 stored,
438 relocated,
439 authoritative_binding_status,
440 bamboo_engine::project_context::WorkspaceSource::Explicit.as_str(),
441 )
442 } else {
443 let (workspace, workspace_source) = if let Some(project) = project.as_ref() {
444 let Some(session) = session.as_ref() else {
445 return Err(ToolError::Execution(
446 "assigned Project has no session context".to_string(),
447 ));
448 };
449 match effective_project_workspace(session, project, &self.workspace_resolver) {
450 Ok((workspace, source)) => (workspace, source.as_str()),
451 Err(error) => {
452 return Ok(completed(false, project_workspace_error(project, error)));
453 }
454 }
455 } else {
456 let preferred = session
457 .as_ref()
458 .and_then(Session::workspace_path_meta)
459 .map(std::path::PathBuf::from);
460 (
461 session_id
462 .and_then(|session_id| {
463 self.workspace_resolver
464 .resolve_session_workspace_candidate(session_id, preferred)
465 })
466 .unwrap_or_else(|| {
467 std::env::current_dir()
468 .unwrap_or_else(|_| std::path::PathBuf::from("."))
469 }),
470 "session",
471 )
472 };
473 let workspace_display = bamboo_config::paths::path_to_display_string(&workspace);
474 let owner = self
475 .projects
476 .find_workspace_owner_for_path(&workspace_display)
477 .map_err(project_tool_error)?;
478 let binding_status = match owner {
479 Some(owner) if project.as_ref().map(|project| &project.id) == Some(&owner.id) => {
480 "registered"
481 }
482 Some(owner) => {
483 return Ok(completed(
484 false,
485 json!({
486 "code": "project_workspace_conflict",
487 "message": "The current workspace is registered to another Project",
488 "workspace": workspace_display,
489 "owner_project_id": owner.id,
490 "session_project_id": project.as_ref().map(|value| value.id.to_string()),
491 }),
492 ));
493 }
494 None => "unregistered",
495 };
496 (workspace, None, binding_status, workspace_source)
497 };
498 Ok(completed(
499 true,
500 self.response(
501 session_id,
502 &workspace,
503 project.as_ref(),
504 binding_status,
505 workspace_source,
506 relocated_from.as_deref(),
507 ),
508 ))
509 }
510}
511
512pub struct ProjectTool {
513 sessions: SessionRepository,
514 projects: Arc<ProjectStore>,
515 workspace_resolver: bamboo_agent_core::workspace_state::WorkspaceResolver,
516 account_sink: Option<Arc<bamboo_engine::events::AccountEventSink>>,
517}
518
519impl ProjectTool {
520 pub fn new(sessions: SessionRepository, projects: Arc<ProjectStore>) -> Self {
521 Self::new_with_workspace_resolver(
522 sessions,
523 projects,
524 bamboo_agent_core::workspace_state::WorkspaceResolver::from_process_globals(),
525 )
526 }
527
528 pub fn new_with_workspace_resolver(
529 sessions: SessionRepository,
530 projects: Arc<ProjectStore>,
531 workspace_resolver: bamboo_agent_core::workspace_state::WorkspaceResolver,
532 ) -> Self {
533 Self {
534 sessions,
535 projects,
536 workspace_resolver,
537 account_sink: None,
538 }
539 }
540
541 pub fn with_account_sink(
542 mut self,
543 account_sink: Arc<bamboo_engine::events::AccountEventSink>,
544 ) -> Self {
545 self.account_sink = Some(account_sink);
546 self
547 }
548
549 async fn current_project(
550 &self,
551 ctx: &ToolCtx,
552 ) -> Result<(String, Session, ProjectManifest), ToolError> {
553 let session_id = ctx.session_id().ok_or_else(|| {
554 ToolError::Execution("Project requires a session_id in tool context".to_string())
555 })?;
556 let session = self
557 .sessions
558 .try_load(session_id)
559 .await
560 .map_err(|error| ToolError::Execution(format!("failed to load session: {error}")))?
561 .ok_or_else(|| ToolError::Execution("session was not found".to_string()))?;
562 let project_id = match ProjectContextResolver::session_project_identity(&session) {
563 SessionProjectIdentity::Assigned(project_id) => project_id,
564 SessionProjectIdentity::Unassigned => {
565 return Err(ToolError::Execution(
566 "session is not assigned to a Project".to_string(),
567 ));
568 }
569 SessionProjectIdentity::Invalid { raw, message } => {
570 return Err(ToolError::Execution(format!(
571 "session carries an invalid assigned Project identity '{raw}': {message}"
572 )));
573 }
574 };
575 let project = self.projects.get(&project_id).map_err(project_tool_error)?;
576 Ok((session_id.to_string(), session, project))
577 }
578}
579
580#[async_trait]
581impl Tool for ProjectTool {
582 fn name(&self) -> &str {
583 "Project"
584 }
585
586 fn description(&self) -> &str {
587 "Inspect the current Project and shared resources, or explicitly bind/unbind workspaces using Project revision CAS."
588 }
589
590 fn classify(&self, args: &Value) -> ToolClass {
591 match args.get("action").and_then(Value::as_str) {
592 Some("inspect" | "list_resources") => ToolClass::READONLY_PARALLEL,
593 _ => ToolClass::MUTATING_SERIAL,
594 }
595 }
596
597 fn parameters_schema(&self) -> Value {
598 json!({
599 "type": "object",
600 "properties": {
601 "action": {
602 "type": "string",
603 "enum": ["inspect", "list_resources", "bind_workspace", "unbind_workspace"]
604 },
605 "path": {"type": "string"},
606 "label": {"type": "string"},
607 "git_common_dir": {"type": "string"},
608 "expected_revision": {"type": "integer", "minimum": 1}
609 },
610 "required": ["action"],
611 "additionalProperties": false
612 })
613 }
614
615 async fn invoke(&self, args: Value, ctx: ToolCtx) -> Result<ToolOutcome, ToolError> {
616 let (session_id, session, project) = self.current_project(&ctx).await?;
617 let action = args
618 .get("action")
619 .and_then(Value::as_str)
620 .ok_or_else(|| ToolError::InvalidArguments("action is required".to_string()))?;
621 match action {
622 "inspect" => {
623 let summary = self
624 .projects
625 .resource_summary(&project.id)
626 .map_err(project_tool_error)?;
627 let resource_revision = summary.resource_revision;
628 let (workspace, workspace_source) =
629 match effective_project_workspace(&session, &project, &self.workspace_resolver)
630 {
631 Ok((workspace, source)) => (Some(workspace), source.as_str()),
632 Err(error) => {
633 return Ok(completed(false, project_workspace_error(&project, error)));
634 }
635 };
636 let (binding_status, owner_project_id) = match workspace.as_deref() {
637 Some(workspace) => {
638 let display = bamboo_config::paths::path_to_display_string(workspace);
639 match self
640 .projects
641 .find_workspace_owner_for_path(&display)
642 .map_err(project_tool_error)?
643 {
644 Some(owner) if owner.id == project.id => ("registered", Some(owner.id)),
645 Some(owner) => ("owned_by_other_project", Some(owner.id)),
646 None => ("unregistered", None),
647 }
648 }
649 None => ("unavailable", None),
650 };
651 Ok(completed(
652 true,
653 json!({
654 "session_id": session_id,
655 "project": project,
656 "project_path": project.project_path,
657 "project_home": bamboo_config::paths::path_to_display_string(
658 &self.projects.paths().project_home(&project.id)
659 ),
660 "resource_summary": summary,
661 "resource_revision": resource_revision,
662 "workspace": workspace.as_deref().map(
663 bamboo_config::paths::path_to_display_string
664 ),
665 "workspace_source": workspace_source,
666 "binding_status": binding_status,
667 "workspace_owner_project_id": owner_project_id,
668 }),
669 ))
670 }
671 "list_resources" => {
672 let summary = self
673 .projects
674 .resource_summary(&project.id)
675 .map_err(project_tool_error)?;
676 Ok(completed(
677 true,
678 json!({
679 "session_id": session_id,
680 "project_id": project.id,
681 "resources": summary,
682 }),
683 ))
684 }
685 "bind_workspace" | "unbind_workspace" => {
686 if project.status != ProjectStatus::Active {
687 return Ok(completed(
688 false,
689 json!({
690 "code": "project_archived",
691 "message": "Archived Projects cannot change workspace bindings"
692 }),
693 ));
694 }
695 let path = args
696 .get("path")
697 .and_then(Value::as_str)
698 .map(str::trim)
699 .filter(|path| !path.is_empty())
700 .ok_or_else(|| ToolError::InvalidArguments("path is required".to_string()))?;
701 let expected = args
702 .get("expected_revision")
703 .and_then(Value::as_u64)
704 .ok_or_else(|| {
705 ToolError::InvalidArguments(
706 "expected_revision is required for binding mutations".to_string(),
707 )
708 })?;
709 let updated = if action == "bind_workspace" {
710 self.projects.bind_workspace(
711 &project.id,
712 expected,
713 WorkspaceBinding {
714 path: path.to_string(),
715 label: args
716 .get("label")
717 .and_then(Value::as_str)
718 .map(ToString::to_string),
719 git_common_dir: args
720 .get("git_common_dir")
721 .and_then(Value::as_str)
722 .map(ToString::to_string),
723 },
724 )
725 } else {
726 self.projects.unbind_workspace(&project.id, expected, path)
727 };
728 match updated {
729 Ok(updated) => {
730 if let Some(sink) = self.account_sink.as_ref() {
731 sink.record(
732 None,
733 &bamboo_agent_core::AgentEvent::ProjectUpdated {
734 project_id: updated.id.to_string(),
735 revision: updated.revision,
736 },
737 );
738 }
739 Ok(completed(
740 true,
741 json!({
742 "session_id": session_id,
743 "project_id": updated.id,
744 "revision": updated.revision,
745 "workspace_bindings": updated.workspace_bindings,
746 }),
747 ))
748 }
749 Err(ProjectStoreError::Conflict { expected, actual }) => Ok(completed(
750 false,
751 json!({
752 "code": "project_revision_conflict",
753 "message": "Project revision precondition failed",
754 "expected_revision": expected,
755 "actual_revision": actual,
756 }),
757 )),
758 Err(ProjectStoreError::ProjectPathUnbindConflict {
759 project_id,
760 project_path,
761 }) => Ok(completed(
762 false,
763 json!({
764 "code": "project_path_unbind_conflict",
765 "message": "Select another Project path before unbinding the current primary folder",
766 "project_id": project_id,
767 "project_path": project_path,
768 }),
769 )),
770 Err(error) => Ok(completed(
771 false,
772 json!({
773 "code": "project_binding_failed",
774 "message": error.to_string(),
775 }),
776 )),
777 }
778 }
779 _ => Err(ToolError::InvalidArguments(format!(
780 "unsupported Project action: {action}"
781 ))),
782 }
783 }
784}
785
786fn project_tool_error(error: ProjectStoreError) -> ToolError {
787 ToolError::Execution(format!("Project registry operation failed: {error}"))
788}
789
790fn completed(success: bool, value: Value) -> ToolOutcome {
791 ToolOutcome::Completed(ToolResult {
792 success,
793 result: value.to_string(),
794 display_preference: Some("Default".to_string()),
795 images: Vec::new(),
796 })
797}
798
799#[cfg(test)]
800mod tests {
801 use super::*;
802 use std::collections::HashMap;
803 use std::time::Duration;
804
805 use bamboo_agent_core::storage::Storage;
806 use bamboo_agent_core::tools::ToolExecutionContext;
807 use bamboo_agent_core::Session;
808 use bamboo_storage::LockedSessionStore;
809 use tokio::sync::RwLock;
810
811 #[derive(Default)]
812 struct TestStorage {
813 sessions: RwLock<HashMap<String, Session>>,
814 }
815
816 #[async_trait]
817 impl Storage for TestStorage {
818 async fn save_session(&self, session: &Session) -> std::io::Result<()> {
819 self.sessions
820 .write()
821 .await
822 .insert(session.id.clone(), session.clone());
823 Ok(())
824 }
825
826 async fn load_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
827 Ok(self.sessions.read().await.get(session_id).cloned())
828 }
829
830 async fn delete_session(&self, session_id: &str) -> std::io::Result<bool> {
831 Ok(self.sessions.write().await.remove(session_id).is_some())
832 }
833 }
834
835 fn context(session_id: &str, tool: &str) -> ToolCtx {
836 ToolExecutionContext {
837 session_id: Some(session_id),
838 tool_call_id: tool,
839 event_tx: None,
840 available_tool_schemas: None,
841 bypass_permissions: false,
842 auto_approve_permissions: false,
843 plan_read_only: false,
844 can_async_resume: false,
845 bash_completion_sink: None,
846 pre_parsed_args: None,
847 }
848 .to_tool_ctx()
849 }
850
851 async fn repository(session: Session) -> SessionRepository {
852 let storage = Arc::new(TestStorage::default());
853 storage.save_session(&session).await.unwrap();
854 let storage: Arc<dyn Storage> = storage;
855 SessionRepository::new(
856 Arc::new(dashmap::DashMap::new()),
857 storage.clone(),
858 Arc::new(LockedSessionStore::new(storage)),
859 )
860 }
861
862 fn result(outcome: ToolOutcome) -> ToolResult {
863 let ToolOutcome::Completed(result) = outcome else {
864 panic!("expected completed tool result")
865 };
866 result
867 }
868
869 #[tokio::test]
870 async fn workspace_reports_registered_project_and_preserves_identity() {
871 let dir = tempfile::tempdir().unwrap();
872 let workspace = dir.path().join("workspace");
873 let nested_workspace = workspace.join("linked-worktree-subdir");
874 std::fs::create_dir_all(&nested_workspace).unwrap();
875 let store = Arc::new(ProjectStore::open(dir.path()).unwrap());
876 let project = store
877 .create_with_project_path("Zenith", None, workspace.to_string_lossy(), Vec::new())
878 .unwrap();
879 let mut session = Session::new("session-1", "model");
880 session.set_project_id_meta(project.id.to_string());
881 let tool = ProjectWorkspaceTool::new(repository(session).await, store.clone());
882
883 let output = result(
884 tool.invoke(
885 json!({"path": nested_workspace}),
886 context("session-1", "Workspace"),
887 )
888 .await
889 .unwrap(),
890 );
891 assert!(output.success);
892 let value: Value = serde_json::from_str(&output.result).unwrap();
893 assert_eq!(value["project_id"], project.id.to_string());
894 assert_eq!(value["project_name"], "Zenith");
895 assert_eq!(
896 value["project_path"],
897 workspace.canonicalize().unwrap().to_string_lossy().as_ref()
898 );
899 assert_eq!(value["workspace_source"], "explicit");
900 assert_eq!(value["binding_status"], "registered");
901 assert!(value["project_home"]
902 .as_str()
903 .is_some_and(|path| path.ends_with(project.id.as_str())));
904 assert!(value.get("relocated_from").is_some());
905 }
906
907 #[tokio::test]
908 async fn workspace_get_tracks_project_default_after_project_path_cas_update() {
909 let dir = tempfile::tempdir().unwrap();
910 let project_path = dir.path().join("project");
911 let moved_project_path = dir.path().join("project-moved");
912 std::fs::create_dir_all(&project_path).unwrap();
913 std::fs::create_dir_all(&moved_project_path).unwrap();
914 let store = Arc::new(ProjectStore::open(dir.path()).unwrap());
915 let project = store
916 .create_with_project_path(
917 "Project default",
918 None,
919 project_path.to_string_lossy(),
920 Vec::new(),
921 )
922 .unwrap();
923 let mut session = Session::new("workspace-project-default", "model");
924 session.set_project_id_meta(project.id.to_string());
925 session.set_workspace_path_meta(bamboo_config::paths::path_to_display_string(
926 &project_path.canonicalize().unwrap(),
927 ));
928 session.metadata.insert(
929 WORKSPACE_SOURCE_METADATA_KEY.to_string(),
930 WorkspaceSource::ProjectDefault.as_str().to_string(),
931 );
932 let sessions = repository(session).await;
933 let updated = store
934 .update_with_project_path(
935 &project.id,
936 project.revision,
937 moved_project_path.to_string_lossy().as_ref(),
938 |_| Ok(()),
939 )
940 .unwrap();
941 let tool = ProjectWorkspaceTool::new(sessions, store);
942
943 let output = result(
944 tool.invoke(json!({}), context("workspace-project-default", "Workspace"))
945 .await
946 .unwrap(),
947 );
948 assert!(output.success);
949 let value: Value = serde_json::from_str(&output.result).unwrap();
950 assert_eq!(
951 value["workspace"],
952 moved_project_path
953 .canonicalize()
954 .unwrap()
955 .to_string_lossy()
956 .as_ref()
957 );
958 assert_eq!(value["workspace_source"], "project_default");
959 assert_eq!(value["binding_status"], "registered");
960 assert_eq!(value["project_path"], value["workspace"]);
961 assert_eq!(value["project_path"], updated.project_path.unwrap());
962 }
963
964 #[tokio::test]
965 async fn workspace_get_preserves_legacy_persisted_workspace_after_project_path_cas_update() {
966 let dir = tempfile::tempdir().unwrap();
967 let project_path = dir.path().join("project");
968 let moved_project_path = dir.path().join("project-moved");
969 std::fs::create_dir_all(&project_path).unwrap();
970 std::fs::create_dir_all(&moved_project_path).unwrap();
971 let store = Arc::new(ProjectStore::open(dir.path()).unwrap());
972 let project = store
973 .create_with_project_path(
974 "Legacy persisted workspace",
975 None,
976 project_path.to_string_lossy(),
977 Vec::new(),
978 )
979 .unwrap();
980 let persisted_workspace =
981 bamboo_config::paths::path_to_display_string(&project_path.canonicalize().unwrap());
982 let mut session = Session::new("workspace-legacy-persisted", "model");
983 session.set_project_id_meta(project.id.to_string());
984 session.set_workspace_path_meta(persisted_workspace.clone());
985 let sessions = repository(session).await;
986 store
987 .update_with_project_path(
988 &project.id,
989 project.revision,
990 moved_project_path.to_string_lossy().as_ref(),
991 |_| Ok(()),
992 )
993 .unwrap();
994 let tool = ProjectWorkspaceTool::new(sessions, store);
995
996 let output = result(
997 tool.invoke(
998 json!({}),
999 context("workspace-legacy-persisted", "Workspace"),
1000 )
1001 .await
1002 .unwrap(),
1003 );
1004 assert!(output.success);
1005 let value: Value = serde_json::from_str(&output.result).unwrap();
1006 assert_eq!(value["workspace"], persisted_workspace);
1007 assert_eq!(value["workspace_source"], "session");
1008 assert_eq!(value["binding_status"], "unregistered");
1009 assert_ne!(value["project_path"], value["workspace"]);
1010 }
1011
1012 #[tokio::test]
1013 async fn workspace_get_rejects_deleted_persisted_workspace_like_runtime() {
1014 let dir = tempfile::tempdir().unwrap();
1015 let project_path = dir.path().join("project");
1016 let persisted_workspace = dir.path().join("persisted-workspace");
1017 std::fs::create_dir_all(&project_path).unwrap();
1018 std::fs::create_dir_all(&persisted_workspace).unwrap();
1019 let store = Arc::new(ProjectStore::open(dir.path()).unwrap());
1020 let project = store
1021 .create_with_project_path(
1022 "Deleted persisted workspace",
1023 None,
1024 project_path.to_string_lossy(),
1025 vec![WorkspaceBinding {
1026 path: persisted_workspace.to_string_lossy().into_owned(),
1027 label: None,
1028 git_common_dir: None,
1029 }],
1030 )
1031 .unwrap();
1032 let mut session = Session::new("workspace-deleted-persisted", "model");
1033 session.set_project_id_meta(project.id.to_string());
1034 let persisted_display = bamboo_config::paths::path_to_display_string(
1035 &persisted_workspace.canonicalize().unwrap(),
1036 );
1037 session.set_workspace_path_meta(persisted_display.clone());
1038 session.metadata.insert(
1039 WORKSPACE_SOURCE_METADATA_KEY.to_string(),
1040 WorkspaceSource::Session.as_str().to_string(),
1041 );
1042 std::fs::remove_dir(&persisted_workspace).unwrap();
1043 let tool = ProjectWorkspaceTool::new(repository(session).await, store);
1044
1045 let output = result(
1046 tool.invoke(
1047 json!({}),
1048 context("workspace-deleted-persisted", "Workspace"),
1049 )
1050 .await
1051 .unwrap(),
1052 );
1053 assert!(!output.success);
1054 let value: Value = serde_json::from_str(&output.result).unwrap();
1055 assert_eq!(value["code"], "workspace_invalid");
1056 assert_eq!(value["workspace"], persisted_display);
1057 }
1058
1059 #[tokio::test]
1060 async fn workspace_get_applies_the_instance_scoped_confinement_policy() {
1061 let dir = tempfile::tempdir().unwrap();
1062 let project_data = dir.path().join("project-data");
1063 let project_path = dir.path().join("project");
1064 let root = dir.path().join("instance-root");
1065 let outside = dir.path().join("outside");
1066 std::fs::create_dir_all(&project_path).unwrap();
1067 std::fs::create_dir_all(&root).unwrap();
1068 std::fs::create_dir_all(&outside).unwrap();
1069 let store = Arc::new(ProjectStore::open(&project_data).unwrap());
1070 let project = store
1071 .create_with_project_path(
1072 "Confined persisted workspace",
1073 None,
1074 project_path.to_string_lossy(),
1075 Vec::new(),
1076 )
1077 .unwrap();
1078 let mut session = Session::new("workspace-get-confined", "model");
1079 session.set_project_id_meta(project.id.to_string());
1080 session.set_workspace_path_meta(bamboo_config::paths::path_to_display_string(
1081 &outside.canonicalize().unwrap(),
1082 ));
1083 session.metadata.insert(
1084 WORKSPACE_SOURCE_METADATA_KEY.to_string(),
1085 WorkspaceSource::Session.as_str().to_string(),
1086 );
1087 let resolver = bamboo_agent_core::workspace_state::WorkspaceResolver::new(|| None, {
1088 let root = root.clone();
1089 move || bamboo_agent_core::workspace_state::WorkspaceRootConfig {
1090 root: root.clone(),
1091 confine: true,
1092 }
1093 });
1094 let tool = ProjectWorkspaceTool::new_with_workspace_resolver(
1095 repository(session).await,
1096 store,
1097 resolver,
1098 );
1099
1100 let output = result(
1101 tool.invoke(json!({}), context("workspace-get-confined", "Workspace"))
1102 .await
1103 .unwrap(),
1104 );
1105 assert!(output.success);
1106 let value: Value = serde_json::from_str(&output.result).unwrap();
1107 let workspace = std::path::PathBuf::from(value["workspace"].as_str().unwrap());
1108 assert!(workspace.starts_with(root.canonicalize().unwrap()));
1109 assert_eq!(value["workspace_source"], "session");
1110 }
1111
1112 #[tokio::test]
1113 async fn workspace_set_uses_the_instance_scoped_confinement_policy() {
1114 let dir = tempfile::tempdir().unwrap();
1115 let project_data = dir.path().join("project-data");
1116 let root = dir.path().join("instance-root");
1117 let outside = dir.path().join("outside");
1118 std::fs::create_dir_all(&project_data).unwrap();
1119 std::fs::create_dir_all(&root).unwrap();
1120 std::fs::create_dir_all(&outside).unwrap();
1121 let store = Arc::new(ProjectStore::open(&project_data).unwrap());
1122 let session = Session::new("instance-confined-workspace", "model");
1123 let resolver = bamboo_agent_core::workspace_state::WorkspaceResolver::new(|| None, {
1124 let root = root.clone();
1125 move || bamboo_agent_core::workspace_state::WorkspaceRootConfig {
1126 root: root.clone(),
1127 confine: true,
1128 }
1129 });
1130 let tool = ProjectWorkspaceTool::new_with_workspace_resolver(
1131 repository(session).await,
1132 store,
1133 resolver,
1134 );
1135
1136 let output = result(
1137 tool.invoke(
1138 json!({"path": outside}),
1139 context("instance-confined-workspace", "Workspace"),
1140 )
1141 .await
1142 .unwrap(),
1143 );
1144 assert!(output.success);
1145 let value: Value = serde_json::from_str(&output.result).unwrap();
1146 let workspace = std::path::PathBuf::from(value["workspace"].as_str().unwrap());
1147 assert!(workspace.starts_with(root.canonicalize().unwrap()));
1148 assert_eq!(
1149 value["relocated_from"],
1150 outside.canonicalize().unwrap().to_string_lossy().as_ref()
1151 );
1152 assert_eq!(value["workspace_source"], "explicit");
1153 assert!(workspace.is_dir());
1154 }
1155
1156 #[tokio::test]
1157 async fn workspace_conflict_is_checked_before_session_state_changes() {
1158 let dir = tempfile::tempdir().unwrap();
1159 let old_workspace = dir.path().join("old");
1160 let owned_workspace = dir.path().join("owned");
1161 let owned_nested = owned_workspace.join("nested");
1162 std::fs::create_dir_all(&old_workspace).unwrap();
1163 std::fs::create_dir_all(&owned_nested).unwrap();
1164 let store = Arc::new(ProjectStore::open(dir.path()).unwrap());
1165 let owner = store
1166 .create_with_bindings(
1167 "Owner",
1168 None,
1169 vec![WorkspaceBinding {
1170 path: owned_workspace.to_string_lossy().into_owned(),
1171 label: None,
1172 git_common_dir: None,
1173 }],
1174 )
1175 .unwrap();
1176 let session_project = store.create("Other", None).unwrap();
1177 let mut session = Session::new("session-conflict", "model");
1178 session.set_project_id_meta(session_project.id.to_string());
1179 session.set_workspace_path_meta(old_workspace.to_string_lossy().into_owned());
1180 session.add_message(bamboo_agent_core::Message::system(format!(
1181 "base\n\n{}\nProject ID: {}\n{}\n\n{}\nWorkspace path: {}\n{}",
1182 bamboo_engine::context::PROJECT_CONTEXT_START_MARKER,
1183 session_project.id,
1184 bamboo_engine::context::PROJECT_CONTEXT_END_MARKER,
1185 bamboo_engine::context::WORKSPACE_CONTEXT_START_MARKER,
1186 old_workspace.display(),
1187 bamboo_engine::context::WORKSPACE_CONTEXT_END_MARKER,
1188 )));
1189 let original_prompt = session.messages[0].content.clone();
1190 bamboo_agent_core::workspace_state::set_workspace(
1191 &session.id,
1192 old_workspace.canonicalize().unwrap(),
1193 );
1194 let repository = repository(session.clone()).await;
1195 let tool = ProjectWorkspaceTool::new(repository.clone(), store);
1196
1197 let output = result(
1198 tool.invoke(
1199 json!({"path": owned_nested}),
1200 context(&session.id, "Workspace"),
1201 )
1202 .await
1203 .unwrap(),
1204 );
1205 assert!(!output.success);
1206 let value: Value = serde_json::from_str(&output.result).unwrap();
1207 assert_eq!(value["code"], "project_workspace_conflict");
1208 assert_eq!(value["owner_project_id"], owner.id.to_string());
1209 assert_eq!(
1210 bamboo_agent_core::workspace_state::get_workspace(&session.id),
1211 Some(old_workspace.canonicalize().unwrap())
1212 );
1213 let persisted = repository
1214 .try_load(&session.id)
1215 .await
1216 .unwrap()
1217 .expect("persisted session");
1218 assert_eq!(
1219 persisted.workspace_path_meta(),
1220 session.workspace_path_meta(),
1221 "conflict must not update session metadata"
1222 );
1223 assert_eq!(
1224 persisted.messages[0].content, original_prompt,
1225 "conflict must not update Project or Workspace prompt blocks"
1226 );
1227 }
1228
1229 #[tokio::test]
1230 async fn workspace_set_preserves_messages_appended_after_the_cached_snapshot() {
1231 let dir = tempfile::tempdir().unwrap();
1232 let workspace = dir.path().join("workspace");
1233 std::fs::create_dir_all(&workspace).unwrap();
1234 let store = Arc::new(ProjectStore::open(dir.path()).unwrap());
1235 let mut session = Session::new("workspace-stale-message", "model");
1236 session.add_message(bamboo_agent_core::Message::user("cached message"));
1237 let repository = repository(session.clone()).await;
1238 let tool = ProjectWorkspaceTool::new(repository.clone(), store);
1239
1240 let mut durable = repository
1241 .storage()
1242 .load_session(&session.id)
1243 .await
1244 .unwrap()
1245 .unwrap();
1246 durable.add_message(bamboo_agent_core::Message::assistant(
1247 "concurrently appended durable message",
1248 None,
1249 ));
1250 repository.storage().save_session(&durable).await.unwrap();
1251
1252 let output = result(
1253 tool.invoke(
1254 json!({"path": workspace}),
1255 context(&session.id, "Workspace"),
1256 )
1257 .await
1258 .unwrap(),
1259 );
1260 assert!(output.success);
1261 let persisted = repository
1262 .storage()
1263 .load_session(&session.id)
1264 .await
1265 .unwrap()
1266 .unwrap();
1267 assert_eq!(persisted.messages.len(), 2);
1268 assert_eq!(
1269 persisted.messages[1].content,
1270 "concurrently appended durable message"
1271 );
1272 }
1273
1274 #[tokio::test]
1275 async fn workspace_set_rechecks_durable_project_after_a_stale_cache_hit() {
1276 let dir = tempfile::tempdir().unwrap();
1277 let workspace = dir.path().join("owned");
1278 std::fs::create_dir_all(&workspace).unwrap();
1279 let store = Arc::new(ProjectStore::open(dir.path()).unwrap());
1280 let owner = store
1281 .create_with_bindings(
1282 "Original owner",
1283 None,
1284 vec![WorkspaceBinding {
1285 path: workspace.to_string_lossy().into_owned(),
1286 label: None,
1287 git_common_dir: None,
1288 }],
1289 )
1290 .unwrap();
1291 let reassigned = store.create("Reassigned", None).unwrap();
1292 let mut session = Session::new("workspace-stale-project", "model");
1293 session.set_project_id_meta(owner.id.to_string());
1294 let repository = repository(session.clone()).await;
1295 let tool = ProjectWorkspaceTool::new(repository.clone(), store);
1296
1297 let mut durable = repository
1298 .storage()
1299 .load_session(&session.id)
1300 .await
1301 .unwrap()
1302 .unwrap();
1303 durable.set_project_id_meta(reassigned.id.to_string());
1304 repository.storage().save_session(&durable).await.unwrap();
1305
1306 let output = result(
1307 tool.invoke(
1308 json!({"path": workspace}),
1309 context(&session.id, "Workspace"),
1310 )
1311 .await
1312 .unwrap(),
1313 );
1314 assert!(!output.success);
1315 let value: Value = serde_json::from_str(&output.result).unwrap();
1316 assert_eq!(value["code"], "project_workspace_conflict");
1317 assert_eq!(value["session_project_id"], reassigned.id.to_string());
1318 let persisted = repository
1319 .storage()
1320 .load_session(&session.id)
1321 .await
1322 .unwrap()
1323 .unwrap();
1324 assert_eq!(
1325 persisted.project_id_meta().as_deref(),
1326 Some(reassigned.id.as_str())
1327 );
1328 assert!(persisted.workspace_path_meta().is_none());
1329 assert!(bamboo_agent_core::workspace_state::peek_workspace(&session.id).is_none());
1330 }
1331
1332 #[tokio::test]
1333 async fn workspace_get_conflict_does_not_publish_persisted_candidate() {
1334 let dir = tempfile::tempdir().unwrap();
1335 let safe_workspace = dir.path().join("safe");
1336 let foreign_workspace = dir.path().join("foreign");
1337 std::fs::create_dir_all(&safe_workspace).unwrap();
1338 std::fs::create_dir_all(&foreign_workspace).unwrap();
1339 let store = Arc::new(ProjectStore::open(dir.path()).unwrap());
1340 let owner = store
1341 .create_with_bindings(
1342 "Owner",
1343 None,
1344 vec![WorkspaceBinding {
1345 path: foreign_workspace.to_string_lossy().into_owned(),
1346 label: None,
1347 git_common_dir: None,
1348 }],
1349 )
1350 .unwrap();
1351 let assigned = store.create("Assigned", None).unwrap();
1352 let mut session = Session::new("workspace-get-conflict", "model");
1353 session.set_project_id_meta(assigned.id.to_string());
1354 session.set_workspace_path_meta(foreign_workspace.to_string_lossy().into_owned());
1355 bamboo_agent_core::workspace_state::set_workspace(
1356 &session.id,
1357 safe_workspace.canonicalize().unwrap(),
1358 );
1359 let tool = ProjectWorkspaceTool::new(repository(session.clone()).await, store);
1360
1361 let output = result(
1362 tool.invoke(json!({}), context(&session.id, "Workspace"))
1363 .await
1364 .unwrap(),
1365 );
1366 assert!(!output.success);
1367 let value: Value = serde_json::from_str(&output.result).unwrap();
1368 assert_eq!(value["code"], "project_workspace_conflict");
1369 assert_eq!(value["owner_project_id"], owner.id.to_string());
1370 assert_eq!(
1371 bamboo_agent_core::workspace_state::get_workspace(&session.id),
1372 Some(safe_workspace.canonicalize().unwrap()),
1373 "GET must not publish the rejected persisted/default candidate"
1374 );
1375 }
1376
1377 #[tokio::test]
1378 async fn invalid_or_missing_assigned_project_fails_closed() {
1379 let dir = tempfile::tempdir().unwrap();
1380 let workspace = dir.path().join("workspace");
1381 let original = dir.path().join("original");
1382 std::fs::create_dir_all(&workspace).unwrap();
1383 std::fs::create_dir_all(&original).unwrap();
1384 let store = Arc::new(ProjectStore::open(dir.path()).unwrap());
1385
1386 for (session_id, project_id) in [
1387 ("invalid-project", "../unsafe"),
1388 ("missing-project", "project-does-not-exist"),
1389 ] {
1390 let mut session = Session::new(session_id, "model");
1391 session
1392 .metadata
1393 .insert("project_id".to_string(), project_id.to_string());
1394 bamboo_agent_core::workspace_state::set_workspace(
1395 &session.id,
1396 original.canonicalize().unwrap(),
1397 );
1398 let tool = ProjectWorkspaceTool::new(repository(session.clone()).await, store.clone());
1399 let error = tool
1400 .invoke(
1401 json!({"path": workspace}),
1402 context(&session.id, "Workspace"),
1403 )
1404 .await
1405 .expect_err("corrupt assigned identity must fail closed");
1406 assert!(matches!(error, ToolError::Execution(_)));
1407 assert_eq!(
1408 bamboo_agent_core::workspace_state::get_workspace(&session.id),
1409 Some(original.canonicalize().unwrap())
1410 );
1411 }
1412 }
1413
1414 #[tokio::test]
1415 async fn project_tool_uses_normalized_three_state_project_identity() {
1416 let dir = tempfile::tempdir().unwrap();
1417 let project_path = dir.path().join("normalized");
1418 std::fs::create_dir_all(&project_path).unwrap();
1419 let store = Arc::new(ProjectStore::open(dir.path()).unwrap());
1420 let project = store
1421 .create_with_project_path(
1422 "Normalized",
1423 None,
1424 project_path.to_string_lossy(),
1425 Vec::new(),
1426 )
1427 .unwrap();
1428
1429 let mut assigned = Session::new("project-tool-trimmed", "model");
1430 assigned
1431 .metadata
1432 .insert("project_id".to_string(), format!(" {} ", project.id));
1433 let tool = ProjectTool::new(repository(assigned).await, store.clone());
1434 let output = result(
1435 tool.invoke(
1436 json!({"action": "inspect"}),
1437 context("project-tool-trimmed", "Project"),
1438 )
1439 .await
1440 .expect("trimmed assigned Project id must resolve"),
1441 );
1442 assert!(output.success);
1443 let value: Value = serde_json::from_str(&output.result).unwrap();
1444 assert_eq!(value["project"]["id"], project.id.to_string());
1445 assert_eq!(
1446 value["project_path"],
1447 project_path
1448 .canonicalize()
1449 .unwrap()
1450 .to_string_lossy()
1451 .as_ref()
1452 );
1453 assert_eq!(value["workspace_source"], "project_default");
1454
1455 let mut invalid = Session::new("project-tool-invalid", "model");
1456 invalid
1457 .metadata
1458 .insert("project_id".to_string(), " ../unsafe ".to_string());
1459 let tool = ProjectTool::new(repository(invalid).await, store);
1460 let error = tool
1461 .invoke(
1462 json!({"action": "inspect"}),
1463 context("project-tool-invalid", "Project"),
1464 )
1465 .await
1466 .expect_err("invalid Project identity must fail closed");
1467 assert!(
1468 matches!(error, ToolError::Execution(ref message) if message.contains("invalid assigned Project identity"))
1469 );
1470 }
1471
1472 #[tokio::test]
1473 async fn project_inspect_reports_redacted_resources_and_workspace_binding_contract() {
1474 let dir = tempfile::tempdir().unwrap();
1475 let workspace = dir.path().join("workspace");
1476 std::fs::create_dir_all(&workspace).unwrap();
1477 let store = Arc::new(ProjectStore::open(dir.path()).unwrap());
1478 let project = store
1479 .create_with_bindings(
1480 "Inspect Contract",
1481 None,
1482 vec![WorkspaceBinding {
1483 path: workspace.to_string_lossy().into_owned(),
1484 label: Some("primary".to_string()),
1485 git_common_dir: None,
1486 }],
1487 )
1488 .unwrap();
1489 let secret = "TOP-SECRET-PROJECT-TOKEN";
1490 std::fs::write(
1491 store.paths().settings_path(&project.id),
1492 format!(r#"{{"api_key":"{secret}"}}"#),
1493 )
1494 .unwrap();
1495 let mut session = Session::new("project-inspect-contract", "model");
1496 session.set_project_id_meta(project.id.to_string());
1497 session.set_workspace_path_meta(workspace.to_string_lossy().into_owned());
1498 let tool = ProjectTool::new(repository(session).await, store);
1499
1500 let output = result(
1501 tool.invoke(
1502 json!({"action": "inspect"}),
1503 context("project-inspect-contract", "Project"),
1504 )
1505 .await
1506 .unwrap(),
1507 );
1508 assert!(output.success);
1509 assert!(!output.result.contains(secret));
1510 assert!(!output.result.contains("api_key"));
1511 let value: Value = serde_json::from_str(&output.result).unwrap();
1512 assert_eq!(value["project"]["id"], project.id.to_string());
1513 assert_eq!(value["resource_revision"], project.resource_revision);
1514 assert_eq!(
1515 value["resource_summary"]["resource_revision"],
1516 project.resource_revision
1517 );
1518 assert_eq!(value["binding_status"], "registered");
1519 assert_eq!(value["workspace_owner_project_id"], project.id.to_string());
1520 assert_eq!(
1521 value["workspace"],
1522 workspace.canonicalize().unwrap().to_string_lossy().as_ref()
1523 );
1524 assert!(value["resource_summary"]["resources"].is_array());
1525 }
1526
1527 #[tokio::test]
1528 async fn project_binding_uses_cas_and_publishes_change_feed_event() {
1529 let dir = tempfile::tempdir().unwrap();
1530 let workspace = dir.path().join("bind");
1531 std::fs::create_dir_all(&workspace).unwrap();
1532 let store = Arc::new(ProjectStore::open(dir.path()).unwrap());
1533 let project = store.create("Project", None).unwrap();
1534 let mut session = Session::new("session-project-tool", "model");
1535 session.set_project_id_meta(project.id.to_string());
1536 let sink = bamboo_engine::events::AccountEventSink::new(dir.path().join("events")).unwrap();
1537 let mut events = sink.subscribe();
1538 let tool =
1539 ProjectTool::new(repository(session).await, store.clone()).with_account_sink(sink);
1540
1541 let stale = result(
1542 tool.invoke(
1543 json!({
1544 "action": "bind_workspace",
1545 "path": workspace,
1546 "expected_revision": project.revision + 1
1547 }),
1548 context("session-project-tool", "Project"),
1549 )
1550 .await
1551 .unwrap(),
1552 );
1553 assert!(!stale.success);
1554 assert_eq!(
1555 serde_json::from_str::<Value>(&stale.result).unwrap()["code"],
1556 "project_revision_conflict"
1557 );
1558
1559 let success = result(
1560 tool.invoke(
1561 json!({
1562 "action": "bind_workspace",
1563 "path": workspace,
1564 "expected_revision": project.revision
1565 }),
1566 context("session-project-tool", "Project"),
1567 )
1568 .await
1569 .unwrap(),
1570 );
1571 assert!(success.success);
1572 let event = tokio::time::timeout(Duration::from_secs(1), events.recv())
1573 .await
1574 .unwrap()
1575 .unwrap();
1576 assert!(matches!(
1577 &event.event,
1578 bamboo_agent_core::AgentEvent::ProjectUpdated { project_id, .. }
1579 if project_id == project.id.as_str()
1580 ));
1581 }
1582}