Skip to main content

bamboo_server_tools/
project_tools.rs

1//! Project-aware `Workspace` and `Project` tools.
2//!
3//! These are server overlays: they keep the framework Workspace semantics but
4//! enrich every response with stable Project identity and enforce the global
5//! workspace binding invariant after confinement has resolved the final path.
6
7use 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            // Resolve confinement before checking the authoritative global
321            // registry. No session state changes until this check passes.
322            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            // Re-load and validate the durable authority while holding the same
352            // per-session lock used by chat and Project reassignment. A cached
353            // snapshot must never overwrite a concurrent message append or
354            // publish a workspace under a Project identity that has changed.
355            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}