Skip to main content

bamboo_server/handlers/agent/
projects.rs

1//! First-class Project registry HTTP API.
2//!
3//! Project manifests are authoritative, revisioned documents. Every mutation
4//! uses optimistic CAS and emits a durable account change-feed event. Resource
5//! responses are counts/revisions only and never include file contents,
6//! environment values, headers, or credentials.
7
8use std::path::Path;
9
10use actix_web::{http::StatusCode, web, HttpRequest, HttpResponse, Result};
11use bamboo_agent_core::AgentEvent;
12use bamboo_domain::{
13    LegacySessionProjectInput, ProjectId, ProjectManifest, ProjectStatus, WorkspaceBinding,
14};
15use bamboo_memory::memory_store::project_key_from_path;
16use bamboo_projects::{
17    canonicalize_workspace_path, plan_legacy_migration, resolve_git_common_dir, ProjectStoreError,
18};
19use serde::Deserialize;
20
21use crate::app_state::AppState;
22
23#[derive(Debug, Deserialize)]
24pub struct CreateProjectRequest {
25    pub name: String,
26    #[serde(default)]
27    pub description: Option<String>,
28    /// Existing user source/work folder. New active Projects cannot be
29    /// created without an authoritative default execution directory.
30    pub project_path: String,
31    #[serde(default)]
32    pub workspace_bindings: Vec<WorkspaceBinding>,
33}
34
35/// Explicitly-present nullable description: absent leaves it unchanged, null clears it.
36fn deserialize_nullable_description<'de, D>(
37    deserializer: D,
38) -> std::result::Result<Option<Option<String>>, D::Error>
39where
40    D: serde::Deserializer<'de>,
41{
42    Option::<String>::deserialize(deserializer).map(Some)
43}
44
45#[derive(Debug, Deserialize)]
46pub struct PatchProjectRequest {
47    #[serde(default)]
48    pub name: Option<String>,
49    #[serde(default, deserialize_with = "deserialize_nullable_description")]
50    pub description: Option<Option<String>>,
51    /// Select a new authoritative Project folder using the same Project CAS
52    /// revision as name/description updates.
53    #[serde(default)]
54    pub project_path: Option<String>,
55}
56
57#[derive(Debug, Deserialize)]
58pub struct WorkspaceMutationRequest {
59    pub path: String,
60    #[serde(default)]
61    pub label: Option<String>,
62    #[serde(default)]
63    pub git_common_dir: Option<String>,
64}
65
66#[derive(Debug, Deserialize)]
67pub struct LegacyDryRunRequest {
68    #[serde(default)]
69    pub sessions: Vec<LegacySessionProjectInput>,
70}
71
72#[derive(Debug, Deserialize)]
73pub struct LegacyMemoryMigrationRequest {
74    pub legacy_project_key: String,
75}
76
77#[derive(Debug, Deserialize)]
78pub struct LegacyMemoryMigrationStatusQuery {
79    pub legacy_project_key: String,
80}
81
82fn missing_legacy_evidence(input: &LegacySessionProjectInput) -> Vec<&'static str> {
83    let mut fields = Vec::new();
84    if input.canonical_path.is_none() {
85        fields.push("canonical_path");
86    }
87    if input.git_common_dir.is_none() {
88        fields.push("git_common_dir");
89    }
90    if input.legacy_project_keys.is_empty() {
91        fields.push("legacy_project_keys");
92    }
93    fields
94}
95
96fn readable_canonical_workspace(workspace_path: &str) -> std::result::Result<String, String> {
97    let canonical = canonicalize_workspace_path(Path::new(workspace_path))
98        .map_err(|error| error.to_string())?;
99    let metadata = std::fs::symlink_metadata(&canonical).map_err(|error| {
100        format!(
101            "canonical Workspace metadata is unavailable ({}): {error}",
102            canonical
103        )
104    })?;
105    if metadata.file_type().is_symlink() || !metadata.is_dir() {
106        return Err(format!(
107            "canonical Workspace is not a plain directory ({canonical})"
108        ));
109    }
110    let mut entries = std::fs::read_dir(&canonical).map_err(|error| {
111        format!(
112            "canonical Workspace is not readable ({}): {error}",
113            canonical
114        )
115    })?;
116    if let Some(entry) = entries.next() {
117        entry.map_err(|error| {
118            format!(
119                "canonical Workspace is not readable ({}): {error}",
120                canonical
121            )
122        })?;
123    }
124    Ok(canonical)
125}
126
127fn enrich_legacy_dry_run_sessions(
128    inputs: &[LegacySessionProjectInput],
129) -> (Vec<LegacySessionProjectInput>, Vec<String>) {
130    let mut diagnostics = Vec::new();
131    let sessions = inputs
132        .iter()
133        .cloned()
134        .map(|mut input| {
135            let missing = missing_legacy_evidence(&input);
136            if missing.is_empty() {
137                return input;
138            }
139            let Some(workspace_path) = input.workspace_path.as_deref() else {
140                diagnostics.push(format!(
141                    "session {} could not enrich {} because workspace_path is absent",
142                    input.session_id,
143                    missing.join(", ")
144                ));
145                return input;
146            };
147            let canonical_workspace = match readable_canonical_workspace(workspace_path) {
148                Ok(canonical) => canonical,
149                Err(error) => {
150                    diagnostics.push(format!(
151                        "session {} could not enrich {} from workspace_path: {error}",
152                        input.session_id,
153                        missing.join(", ")
154                    ));
155                    return input;
156                }
157            };
158
159            if input.canonical_path.is_none() {
160                input.canonical_path = Some(canonical_workspace.clone());
161            }
162            if input.git_common_dir.is_none() {
163                match resolve_git_common_dir(Path::new(&canonical_workspace)) {
164                    Ok(git_common_dir) => input.git_common_dir = git_common_dir,
165                    Err(error) => diagnostics.push(format!(
166                        "session {} could not enrich git_common_dir from workspace_path: {error}",
167                        input.session_id
168                    )),
169                }
170            }
171            if input.legacy_project_keys.is_empty() {
172                input
173                    .legacy_project_keys
174                    .push(project_key_from_path(Path::new(&canonical_workspace)));
175            }
176            input
177        })
178        .collect();
179    (sessions, diagnostics)
180}
181
182fn parse_if_match(req: &HttpRequest) -> std::result::Result<u64, HttpResponse> {
183    let Some(raw) = req.headers().get(actix_web::http::header::IF_MATCH) else {
184        return Err(crate::error::json_error(
185            StatusCode::PRECONDITION_REQUIRED,
186            "If-Match with the current Project revision is required",
187        ));
188    };
189    let value = raw
190        .to_str()
191        .ok()
192        .map(str::trim)
193        .and_then(|value| value.strip_prefix("W/").or(Some(value)))
194        .map(|value| value.trim_matches('"'))
195        .and_then(|value| value.parse::<u64>().ok());
196    value.ok_or_else(|| {
197        crate::error::json_error(
198            StatusCode::BAD_REQUEST,
199            "If-Match must be a Project revision integer or quoted ETag",
200        )
201    })
202}
203
204fn parse_id(raw: &str) -> std::result::Result<ProjectId, HttpResponse> {
205    raw.parse::<ProjectId>()
206        .map_err(|_| crate::error::json_error(StatusCode::BAD_REQUEST, "invalid Project id"))
207}
208
209fn project_error(error: ProjectStoreError) -> HttpResponse {
210    if let ProjectStoreError::NotArchived(project_id) = &error {
211        return HttpResponse::Conflict().json(serde_json::json!({
212            "error": {
213                "type": "api_error",
214                "code": "project_not_archived",
215                "message": "Project is not archived"
216            },
217            "project_id": project_id,
218        }));
219    }
220    if let ProjectStoreError::ProjectPathUnbindConflict {
221        project_id,
222        project_path,
223    } = &error
224    {
225        return HttpResponse::Conflict().json(serde_json::json!({
226            "error": {
227                "type": "api_error",
228                "code": "project_path_unbind_conflict",
229                "message": "Select another Project path before unbinding the current primary folder"
230            },
231            "project_id": project_id,
232            "project_path": project_path,
233        }));
234    }
235    let (status, message) = match error {
236        ProjectStoreError::NotFound(_) => (StatusCode::NOT_FOUND, error.to_string()),
237        ProjectStoreError::Conflict { .. } => (StatusCode::PRECONDITION_FAILED, error.to_string()),
238        ProjectStoreError::AlreadyExists(_)
239        | ProjectStoreError::NotArchived(_)
240        | ProjectStoreError::Validation(_)
241        | ProjectStoreError::InvalidPathComponent(_)
242        | ProjectStoreError::ProjectPathUnbindConflict { .. } => {
243            (StatusCode::CONFLICT, error.to_string())
244        }
245        ProjectStoreError::Io(_) | ProjectStoreError::Json(_) => {
246            tracing::error!(%error, "Project registry operation failed");
247            (
248                StatusCode::INTERNAL_SERVER_ERROR,
249                "Project registry operation failed".to_string(),
250            )
251        }
252    };
253    crate::error::json_error(status, message)
254}
255
256fn project_path_validation_error(
257    error: crate::project_context::ProjectWorkspaceValidationError,
258) -> HttpResponse {
259    match error {
260        crate::project_context::ProjectWorkspaceValidationError::Invalid {
261            code,
262            workspace,
263            message,
264        } => HttpResponse::Conflict().json(serde_json::json!({
265            "error": {
266                "type": "api_error",
267                "code": code,
268                "message": message
269            },
270            "project_path": workspace,
271        })),
272        crate::project_context::ProjectWorkspaceValidationError::Conflict {
273            workspace,
274            owner_project_id,
275            session_project_id,
276        } => HttpResponse::Conflict().json(serde_json::json!({
277            "error": {
278                "type": "api_error",
279                "code": "project_path_conflict",
280                "message": "Project path belongs to another Project"
281            },
282            "project_path": workspace,
283            "owner_project_id": owner_project_id,
284            "project_id": session_project_id,
285        })),
286        crate::project_context::ProjectWorkspaceValidationError::Store(error) => {
287            project_error(error)
288        }
289    }
290}
291
292fn with_etag(project: &ProjectManifest, status: StatusCode) -> HttpResponse {
293    HttpResponse::build(status)
294        .insert_header((
295            actix_web::http::header::ETAG,
296            format!("\"{}\"", project.revision),
297        ))
298        .json(project)
299}
300
301pub async fn list_projects(state: web::Data<AppState>) -> Result<HttpResponse> {
302    match state.project_store.list() {
303        Ok(projects) => Ok(HttpResponse::Ok().json(serde_json::json!({ "projects": projects }))),
304        Err(error) => Ok(project_error(error)),
305    }
306}
307
308pub async fn create_project(
309    state: web::Data<AppState>,
310    request: web::Json<CreateProjectRequest>,
311) -> Result<HttpResponse> {
312    let project_path = match crate::project_context::validate_project_path_candidate_with_resolver(
313        &state.project_store,
314        None,
315        &request.project_path,
316        &state.workspace_resolver,
317    ) {
318        Ok(project_path) => bamboo_config::paths::path_to_display_string(&project_path),
319        Err(error) => return Ok(project_path_validation_error(error)),
320    };
321    let project = match state.project_store.create_with_project_path(
322        request.name.clone(),
323        request.description.clone(),
324        project_path,
325        request.workspace_bindings.clone(),
326    ) {
327        Ok(project) => project,
328        Err(error) => return Ok(project_error(error)),
329    };
330    state.account_sink.record(
331        None,
332        &AgentEvent::ProjectCreated {
333            project_id: project.id.to_string(),
334            revision: project.revision,
335        },
336    );
337    Ok(with_etag(&project, StatusCode::CREATED))
338}
339
340pub async fn get_project(
341    state: web::Data<AppState>,
342    path: web::Path<String>,
343) -> Result<HttpResponse> {
344    let id = match parse_id(&path) {
345        Ok(id) => id,
346        Err(response) => return Ok(response),
347    };
348    match state.project_store.get(&id) {
349        Ok(project) => Ok(with_etag(&project, StatusCode::OK)),
350        Err(error) => Ok(project_error(error)),
351    }
352}
353
354pub async fn patch_project(
355    state: web::Data<AppState>,
356    path: web::Path<String>,
357    http_request: HttpRequest,
358    request: web::Json<PatchProjectRequest>,
359) -> Result<HttpResponse> {
360    let id = match parse_id(&path) {
361        Ok(id) => id,
362        Err(response) => return Ok(response),
363    };
364    let expected = match parse_if_match(&http_request) {
365        Ok(revision) => revision,
366        Err(response) => return Ok(response),
367    };
368    let mutate = |project: &mut ProjectManifest| {
369        if let Some(name) = request.name.as_ref() {
370            project.name = name.clone();
371        }
372        if let Some(description) = request.description.as_ref() {
373            project.description = description.clone();
374        }
375        Ok(())
376    };
377    let result = if let Some(project_path) = request.project_path.as_deref() {
378        let project_path =
379            match crate::project_context::validate_project_path_candidate_with_resolver(
380                &state.project_store,
381                Some(&id),
382                project_path,
383                &state.workspace_resolver,
384            ) {
385                Ok(project_path) => bamboo_config::paths::path_to_display_string(&project_path),
386                Err(error) => return Ok(project_path_validation_error(error)),
387            };
388        state
389            .project_store
390            .update_with_project_path(&id, expected, &project_path, mutate)
391    } else {
392        state.project_store.update(&id, expected, mutate)
393    };
394    let project = match result {
395        Ok(project) => project,
396        Err(error) => return Ok(project_error(error)),
397    };
398    state.account_sink.record(
399        None,
400        &AgentEvent::ProjectUpdated {
401            project_id: project.id.to_string(),
402            revision: project.revision,
403        },
404    );
405    Ok(with_etag(&project, StatusCode::OK))
406}
407
408pub async fn bind_workspace(
409    state: web::Data<AppState>,
410    path: web::Path<String>,
411    http_request: HttpRequest,
412    request: web::Json<WorkspaceMutationRequest>,
413) -> Result<HttpResponse> {
414    let id = match parse_id(&path) {
415        Ok(id) => id,
416        Err(response) => return Ok(response),
417    };
418    let expected = match parse_if_match(&http_request) {
419        Ok(revision) => revision,
420        Err(response) => return Ok(response),
421    };
422    let project = match state.project_store.bind_workspace(
423        &id,
424        expected,
425        WorkspaceBinding {
426            path: request.path.clone(),
427            label: request.label.clone(),
428            git_common_dir: request.git_common_dir.clone(),
429        },
430    ) {
431        Ok(project) => project,
432        Err(error) => return Ok(project_error(error)),
433    };
434    state.account_sink.record(
435        None,
436        &AgentEvent::ProjectUpdated {
437            project_id: project.id.to_string(),
438            revision: project.revision,
439        },
440    );
441    Ok(with_etag(&project, StatusCode::OK))
442}
443
444pub async fn unbind_workspace(
445    state: web::Data<AppState>,
446    path: web::Path<String>,
447    http_request: HttpRequest,
448    request: web::Json<WorkspaceMutationRequest>,
449) -> Result<HttpResponse> {
450    let id = match parse_id(&path) {
451        Ok(id) => id,
452        Err(response) => return Ok(response),
453    };
454    let expected = match parse_if_match(&http_request) {
455        Ok(revision) => revision,
456        Err(response) => return Ok(response),
457    };
458    let project = match state
459        .project_store
460        .unbind_workspace(&id, expected, &request.path)
461    {
462        Ok(project) => project,
463        Err(error) => return Ok(project_error(error)),
464    };
465    state.account_sink.record(
466        None,
467        &AgentEvent::ProjectUpdated {
468            project_id: project.id.to_string(),
469            revision: project.revision,
470        },
471    );
472    Ok(with_etag(&project, StatusCode::OK))
473}
474
475pub async fn project_resources(
476    state: web::Data<AppState>,
477    path: web::Path<String>,
478) -> Result<HttpResponse> {
479    let id = match parse_id(&path) {
480        Ok(id) => id,
481        Err(response) => return Ok(response),
482    };
483    match state.project_store.resource_summary(&id) {
484        Ok(summary) => Ok(HttpResponse::Ok().json(summary)),
485        Err(error) => Ok(project_error(error)),
486    }
487}
488
489pub async fn archive_project(
490    state: web::Data<AppState>,
491    path: web::Path<String>,
492    http_request: HttpRequest,
493) -> Result<HttpResponse> {
494    let id = match parse_id(&path) {
495        Ok(id) => id,
496        Err(response) => return Ok(response),
497    };
498    let expected = match parse_if_match(&http_request) {
499        Ok(revision) => revision,
500        Err(response) => return Ok(response),
501    };
502    let project = match state.project_store.archive(&id, expected) {
503        Ok(project) => project,
504        Err(error) => return Ok(project_error(error)),
505    };
506    state.account_sink.record(
507        None,
508        &AgentEvent::ProjectArchived {
509            project_id: project.id.to_string(),
510            revision: project.revision,
511        },
512    );
513    Ok(with_etag(&project, StatusCode::OK))
514}
515
516pub async fn unarchive_project(
517    state: web::Data<AppState>,
518    path: web::Path<String>,
519    http_request: HttpRequest,
520) -> Result<HttpResponse> {
521    let id = match parse_id(&path) {
522        Ok(id) => id,
523        Err(response) => return Ok(response),
524    };
525    let expected = match parse_if_match(&http_request) {
526        Ok(revision) => revision,
527        Err(response) => return Ok(response),
528    };
529    let project = match state.project_store.unarchive(&id, expected) {
530        Ok(project) => project,
531        Err(error) => return Ok(project_error(error)),
532    };
533    state.account_sink.record(
534        None,
535        &AgentEvent::ProjectUpdated {
536            project_id: project.id.to_string(),
537            revision: project.revision,
538        },
539    );
540    Ok(with_etag(&project, StatusCode::OK))
541}
542
543pub async fn legacy_dry_run(
544    state: web::Data<AppState>,
545    request: web::Json<LegacyDryRunRequest>,
546) -> Result<HttpResponse> {
547    let projects = match state.project_store.list() {
548        Ok(projects) => projects,
549        Err(error) => return Ok(project_error(error)),
550    };
551    let (sessions, diagnostics) = enrich_legacy_dry_run_sessions(&request.sessions);
552    let mut report = plan_legacy_migration(&sessions, &projects);
553    report.diagnostics.extend(diagnostics);
554    Ok(HttpResponse::Ok().json(report))
555}
556
557pub async fn migrate_legacy_memory(
558    state: web::Data<AppState>,
559    path: web::Path<String>,
560    http_request: HttpRequest,
561    request: web::Json<LegacyMemoryMigrationRequest>,
562) -> Result<HttpResponse> {
563    let id = match parse_id(&path) {
564        Ok(id) => id,
565        Err(response) => return Ok(response),
566    };
567    let expected = match parse_if_match(&http_request) {
568        Ok(revision) => revision,
569        Err(response) => return Ok(response),
570    };
571    let current = match state.project_store.get(&id) {
572        Ok(project) => project,
573        Err(error) => return Ok(project_error(error)),
574    };
575    if current.revision != expected {
576        return Ok(project_error(ProjectStoreError::Conflict {
577            expected,
578            actual: current.revision,
579        }));
580    }
581    let project = if current
582        .legacy_project_keys
583        .iter()
584        .any(|key| key == &request.legacy_project_key)
585    {
586        current
587    } else {
588        match state.project_store.update(&id, expected, |project| {
589            project
590                .legacy_project_keys
591                .push(request.legacy_project_key.clone());
592            Ok(())
593        }) {
594            Ok(project) => {
595                state.account_sink.record(
596                    None,
597                    &AgentEvent::ProjectUpdated {
598                        project_id: project.id.to_string(),
599                        revision: project.revision,
600                    },
601                );
602                project
603            }
604            Err(error) => return Ok(project_error(error)),
605        }
606    };
607    match state
608        .project_store
609        .migrate_legacy_memory(&id, &request.legacy_project_key)
610    {
611        Ok(report) => {
612            let authoritative = match state.project_store.get(&id) {
613                Ok(project) => project,
614                Err(error) => return Ok(project_error(error)),
615            };
616            if authoritative.revision != project.revision {
617                state.account_sink.record(
618                    None,
619                    &AgentEvent::ProjectUpdated {
620                        project_id: authoritative.id.to_string(),
621                        revision: authoritative.revision,
622                    },
623                );
624            }
625            Ok(HttpResponse::Ok()
626                .insert_header((
627                    actix_web::http::header::ETAG,
628                    format!("\"{}\"", authoritative.revision),
629                ))
630                .json(serde_json::json!({
631                    "project_id": id,
632                    "project_revision": authoritative.revision,
633                    "migration": report,
634                })))
635        }
636        Err(error) => Ok(project_error(error)),
637    }
638}
639
640pub async fn legacy_memory_migration_status(
641    state: web::Data<AppState>,
642    path: web::Path<String>,
643    query: web::Query<LegacyMemoryMigrationStatusQuery>,
644) -> Result<HttpResponse> {
645    let id = match parse_id(&path) {
646        Ok(id) => id,
647        Err(response) => return Ok(response),
648    };
649    match state
650        .project_store
651        .legacy_memory_migration_status(&id, &query.legacy_project_key)
652    {
653        Ok(status) => Ok(HttpResponse::Ok().json(serde_json::json!({
654            "project_id": id,
655            "legacy_project_key": query.legacy_project_key,
656            "migration": status,
657        }))),
658        Err(error) => Ok(project_error(error)),
659    }
660}
661
662pub fn is_active(project: &ProjectManifest) -> bool {
663    project.status == ProjectStatus::Active
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669    use std::collections::BTreeSet;
670    use std::process::Command;
671
672    use actix_web::{http::header, test, App};
673    use bamboo_domain::{LegacyProjectDryRunReport, LegacyProjectMatchBasis};
674    use serde_json::Value;
675
676    async fn app_state() -> (tempfile::TempDir, web::Data<AppState>) {
677        let dir = tempfile::tempdir().unwrap();
678        let state = AppState::new(dir.path().to_path_buf())
679            .await
680            .expect("test AppState");
681        (dir, web::Data::new(state))
682    }
683
684    fn run_git(cwd: &Path, args: &[&str]) {
685        let output = Command::new("git")
686            .current_dir(cwd)
687            .args(args)
688            .env_remove("GIT_DIR")
689            .env_remove("GIT_WORK_TREE")
690            .env_remove("GIT_COMMON_DIR")
691            .output()
692            .expect("git must be installed for migration dry-run tests");
693        assert!(
694            output.status.success(),
695            "git {args:?} failed: {}",
696            String::from_utf8_lossy(&output.stderr)
697        );
698    }
699
700    fn initialize_git_repository(root: &Path) {
701        std::fs::create_dir_all(root).unwrap();
702        run_git(root, &["init", "-q"]);
703        run_git(
704            root,
705            &["config", "user.email", "legacy-dry-run@example.test"],
706        );
707        run_git(root, &["config", "user.name", "Legacy Dry Run Test"]);
708        run_git(root, &["config", "commit.gpgsign", "false"]);
709        std::fs::write(root.join("README.md"), "legacy dry-run\n").unwrap();
710        run_git(root, &["add", "README.md"]);
711        run_git(root, &["commit", "-q", "-m", "initial"]);
712    }
713
714    macro_rules! project_app {
715        ($state:expr) => {
716            App::new()
717                .app_data($state)
718                .route("/projects", web::get().to(list_projects))
719                .route("/projects", web::post().to(create_project))
720                .route("/projects/{id}", web::get().to(get_project))
721                .route("/projects/{id}", web::patch().to(patch_project))
722                .route("/projects/{id}/workspaces", web::post().to(bind_workspace))
723                .route(
724                    "/projects/{id}/workspaces",
725                    web::delete().to(unbind_workspace),
726                )
727                .route("/projects/{id}/resources", web::get().to(project_resources))
728                .route("/projects/{id}/archive", web::post().to(archive_project))
729                .route(
730                    "/projects/{id}/unarchive",
731                    web::post().to(unarchive_project),
732                )
733                .route(
734                    "/projects/migrations/legacy/dry-run",
735                    web::post().to(legacy_dry_run),
736                )
737                .route(
738                    "/projects/{id}/migrations/legacy-memory",
739                    web::post().to(migrate_legacy_memory),
740                )
741                .route(
742                    "/projects/{id}/migrations/legacy-memory",
743                    web::get().to(legacy_memory_migration_status),
744                )
745        };
746    }
747
748    #[actix_web::test]
749    async fn create_project_rejects_unavailable_path_without_registry_side_effects() {
750        let (dir, state) = app_state().await;
751        let app = test::init_service(project_app!(state.clone())).await;
752        let missing = dir.path().join("missing-project");
753
754        let response = test::call_service(
755            &app,
756            test::TestRequest::post()
757                .uri("/projects")
758                .set_json(serde_json::json!({
759                    "name": "Must not persist",
760                    "project_path": missing
761                }))
762                .to_request(),
763        )
764        .await;
765        assert_eq!(response.status(), StatusCode::CONFLICT);
766        let body: Value = test::read_body_json(response).await;
767        assert_eq!(
768            body["error"]["code"], "project_path_unavailable",
769            "unexpected response: {body}"
770        );
771        assert!(state.project_store.list().unwrap().is_empty());
772    }
773
774    #[actix_web::test]
775    async fn project_routes_enforce_etag_cas_and_keep_identity_stable() {
776        let (dir, state) = app_state().await;
777        let mut feed = state.account_sink.subscribe();
778        let app = test::init_service(project_app!(state.clone())).await;
779        let project_path = dir.path().join("zenith");
780        std::fs::create_dir_all(&project_path).unwrap();
781
782        let create = test::call_service(
783            &app,
784            test::TestRequest::post()
785                .uri("/projects")
786                .set_json(serde_json::json!({
787                    "name": "Zenith",
788                    "description": "first",
789                    "project_path": project_path
790                }))
791                .to_request(),
792        )
793        .await;
794        assert_eq!(create.status(), StatusCode::CREATED);
795        assert_eq!(create.headers().get(header::ETAG).unwrap(), "\"1\"");
796        let created: ProjectManifest = test::read_body_json(create).await;
797        assert_eq!(
798            created.project_path.as_deref(),
799            Some(
800                project_path
801                    .canonicalize()
802                    .unwrap()
803                    .to_string_lossy()
804                    .as_ref()
805            )
806        );
807        assert_eq!(
808            created.project_path_status,
809            bamboo_domain::ProjectPathStatus::Configured
810        );
811        let home = state.project_store.paths().project_home(&created.id);
812        assert!(home.ends_with(created.id.as_str()));
813        let event = tokio::time::timeout(std::time::Duration::from_secs(1), feed.recv())
814            .await
815            .unwrap()
816            .unwrap();
817        assert!(matches!(
818            &event.event,
819            AgentEvent::ProjectCreated { project_id, .. } if project_id == created.id.as_str()
820        ));
821
822        let missing_precondition = test::call_service(
823            &app,
824            test::TestRequest::patch()
825                .uri(&format!("/projects/{}", created.id))
826                .set_json(serde_json::json!({"name":"Renamed"}))
827                .to_request(),
828        )
829        .await;
830        assert_eq!(
831            missing_precondition.status(),
832            StatusCode::PRECONDITION_REQUIRED
833        );
834
835        let stale = test::call_service(
836            &app,
837            test::TestRequest::patch()
838                .uri(&format!("/projects/{}", created.id))
839                .insert_header((header::IF_MATCH, "\"9\""))
840                .set_json(serde_json::json!({"name":"Renamed"}))
841                .to_request(),
842        )
843        .await;
844        assert_eq!(stale.status(), StatusCode::PRECONDITION_FAILED);
845
846        let patched = test::call_service(
847            &app,
848            test::TestRequest::patch()
849                .uri(&format!("/projects/{}", created.id))
850                .insert_header((header::IF_MATCH, "\"1\""))
851                .set_json(serde_json::json!({"name":"Renamed"}))
852                .to_request(),
853        )
854        .await;
855        assert_eq!(patched.status(), StatusCode::OK);
856        assert_eq!(patched.headers().get(header::ETAG).unwrap(), "\"2\"");
857        let renamed: ProjectManifest = test::read_body_json(patched).await;
858        assert_eq!(renamed.id, created.id);
859        assert_eq!(renamed.name, "Renamed");
860        assert_eq!(state.project_store.paths().project_home(&renamed.id), home);
861        let updated_event = tokio::time::timeout(std::time::Duration::from_secs(1), feed.recv())
862            .await
863            .unwrap()
864            .unwrap();
865        assert!(matches!(
866            &updated_event.event,
867            AgentEvent::ProjectUpdated { project_id, .. } if project_id == renamed.id.as_str()
868        ));
869
870        let moved_project_path = dir.path().join("zenith-moved");
871        std::fs::create_dir_all(&moved_project_path).unwrap();
872        let moved = test::call_service(
873            &app,
874            test::TestRequest::patch()
875                .uri(&format!("/projects/{}", renamed.id))
876                .insert_header((header::IF_MATCH, "\"2\""))
877                .set_json(serde_json::json!({"project_path": moved_project_path}))
878                .to_request(),
879        )
880        .await;
881        assert_eq!(moved.status(), StatusCode::OK);
882        assert_eq!(moved.headers().get(header::ETAG).unwrap(), "\"3\"");
883        let moved: ProjectManifest = test::read_body_json(moved).await;
884        assert_eq!(moved.id, created.id);
885        assert_eq!(
886            moved.project_path.as_deref(),
887            Some(
888                moved_project_path
889                    .canonicalize()
890                    .unwrap()
891                    .to_string_lossy()
892                    .as_ref()
893            )
894        );
895        let path_updated_event =
896            tokio::time::timeout(std::time::Duration::from_secs(1), feed.recv())
897                .await
898                .unwrap()
899                .unwrap();
900        assert!(matches!(
901            &path_updated_event.event,
902            AgentEvent::ProjectUpdated {
903                project_id,
904                revision: 3,
905            } if project_id == renamed.id.as_str()
906        ));
907
908        let primary_unbind = test::call_service(
909            &app,
910            test::TestRequest::delete()
911                .uri(&format!("/projects/{}/workspaces", renamed.id))
912                .insert_header((header::IF_MATCH, "\"3\""))
913                .set_json(serde_json::json!({"path": moved_project_path}))
914                .to_request(),
915        )
916        .await;
917        assert_eq!(primary_unbind.status(), StatusCode::CONFLICT);
918        let primary_unbind: Value = test::read_body_json(primary_unbind).await;
919        assert_eq!(
920            primary_unbind["error"]["code"],
921            "project_path_unbind_conflict"
922        );
923        assert_eq!(
924            state.project_store.get(&renamed.id).unwrap().project_path,
925            moved.project_path
926        );
927
928        let listed =
929            test::call_service(&app, test::TestRequest::get().uri("/projects").to_request()).await;
930        assert_eq!(listed.status(), StatusCode::OK);
931        let listed: Value = test::read_body_json(listed).await;
932        assert_eq!(listed["projects"][0]["id"], renamed.id.to_string());
933        assert_eq!(listed["projects"][0]["project_path_status"], "configured");
934
935        // Resource API returns only counts/revisions; file contents and secret
936        // values never cross the contract.
937        let commands = home.join("commands");
938        std::fs::create_dir_all(&commands).unwrap();
939        std::fs::write(commands.join("private.md"), "TOP-SECRET-VALUE").unwrap();
940        let resources = test::call_service(
941            &app,
942            test::TestRequest::get()
943                .uri(&format!("/projects/{}/resources", renamed.id))
944                .to_request(),
945        )
946        .await;
947        assert_eq!(resources.status(), StatusCode::OK);
948        let body = test::read_body(resources).await;
949        let body = String::from_utf8(body.to_vec()).unwrap();
950        assert!(!body.contains("TOP-SECRET-VALUE"));
951        assert!(body.contains("resource_revision"));
952
953        let legacy_key = "legacy-key";
954        let legacy_root = dir
955            .path()
956            .join("memory/v1/scopes/projects")
957            .join(legacy_key);
958        std::fs::create_dir_all(&legacy_root).unwrap();
959        std::fs::write(legacy_root.join("index.json"), r#"{"legacy":true}"#).unwrap();
960        let migration_revision = state.project_store.get(&renamed.id).unwrap().revision;
961        let migration = test::call_service(
962            &app,
963            test::TestRequest::post()
964                .uri(&format!(
965                    "/projects/{}/migrations/legacy-memory",
966                    renamed.id
967                ))
968                .insert_header((header::IF_MATCH, format!("\"{migration_revision}\"")))
969                .set_json(serde_json::json!({"legacy_project_key": legacy_key}))
970                .to_request(),
971        )
972        .await;
973        assert_eq!(migration.status(), StatusCode::OK);
974        let migration_etag = migration
975            .headers()
976            .get(header::ETAG)
977            .expect("migration ETag")
978            .to_str()
979            .unwrap()
980            .to_string();
981        let migration_body: Value = test::read_body_json(migration).await;
982        assert_eq!(migration_body["migration"]["phase"], "committed");
983        let response_revision = migration_body["project_revision"]
984            .as_u64()
985            .expect("project revision");
986        assert_eq!(migration_etag, format!("\"{response_revision}\""));
987        assert_eq!(
988            state.project_store.get(&renamed.id).unwrap().revision,
989            response_revision,
990            "endpoint must return the authoritative post-migration CAS revision"
991        );
992        let mut observed_final_event = false;
993        for _ in 0..8 {
994            let event = tokio::time::timeout(std::time::Duration::from_secs(1), feed.recv())
995                .await
996                .unwrap()
997                .unwrap();
998            if matches!(
999                &event.event,
1000                AgentEvent::ProjectUpdated {
1001                    project_id,
1002                    revision,
1003                } if project_id == renamed.id.as_str() && *revision == response_revision
1004            ) {
1005                observed_final_event = true;
1006                break;
1007            }
1008        }
1009        assert!(
1010            observed_final_event,
1011            "post-migration revision must be published to the Project change feed"
1012        );
1013        let before_external_write = state.project_store.get(&renamed.id).unwrap().revision;
1014        std::fs::write(
1015            commands.join("independent.md"),
1016            "independent external change",
1017        )
1018        .unwrap();
1019        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(3);
1020        loop {
1021            let current = state.project_store.get(&renamed.id).unwrap().revision;
1022            if current > before_external_write {
1023                break;
1024            }
1025            assert!(
1026                tokio::time::Instant::now() < deadline,
1027                "an external resource write interleaved after migration must advance revision"
1028            );
1029            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
1030        }
1031        assert!(legacy_root.join("index.json").is_file());
1032        assert!(home.join("memory/v1/index.json").is_file());
1033
1034        let migration_status = test::call_service(
1035            &app,
1036            test::TestRequest::get()
1037                .uri(&format!(
1038                    "/projects/{}/migrations/legacy-memory?legacy_project_key={legacy_key}",
1039                    renamed.id
1040                ))
1041                .to_request(),
1042        )
1043        .await;
1044        assert_eq!(migration_status.status(), StatusCode::OK);
1045        let status_body: Value = test::read_body_json(migration_status).await;
1046        assert_eq!(status_body["migration"]["phase"], "committed");
1047
1048        let archive_revision = state.project_store.get(&renamed.id).unwrap().revision;
1049        let archive = test::call_service(
1050            &app,
1051            test::TestRequest::post()
1052                .uri(&format!("/projects/{}/archive", renamed.id))
1053                .insert_header((header::IF_MATCH, format!("\"{archive_revision}\"")))
1054                .to_request(),
1055        )
1056        .await;
1057        assert_eq!(archive.status(), StatusCode::OK);
1058        let archived: ProjectManifest = test::read_body_json(archive).await;
1059        assert_eq!(archived.status, ProjectStatus::Archived);
1060        loop {
1061            let event = tokio::time::timeout(std::time::Duration::from_secs(1), feed.recv())
1062                .await
1063                .unwrap()
1064                .unwrap();
1065            if matches!(
1066                &event.event,
1067                AgentEvent::ProjectArchived { project_id, .. }
1068                    if project_id == archived.id.as_str()
1069            ) {
1070                break;
1071            }
1072        }
1073
1074        let dry_run = test::call_service(
1075            &app,
1076            test::TestRequest::post()
1077                .uri("/projects/migrations/legacy/dry-run")
1078                .set_json(serde_json::json!({"sessions":[]}))
1079                .to_request(),
1080        )
1081        .await;
1082        assert_eq!(dry_run.status(), StatusCode::OK);
1083        drop(dir);
1084    }
1085
1086    #[actix_web::test]
1087    async fn legacy_dry_run_enriches_exact_match_without_persisting() {
1088        let (dir, state) = app_state().await;
1089        let workspace = dir.path().join("exact-workspace");
1090        let alias_child = workspace.join("alias");
1091        std::fs::create_dir_all(&alias_child).unwrap();
1092        let project = state
1093            .project_store
1094            .create_with_project_path("Exact match", None, workspace.to_string_lossy(), Vec::new())
1095            .unwrap();
1096        let before = state.project_store.get(&project.id).unwrap();
1097        let manifest_path = state.project_store.paths().manifest_path(&project.id);
1098        let index_path = state.project_store.paths().index_path();
1099        let manifest_bytes = std::fs::read(&manifest_path).unwrap();
1100        let index_bytes = std::fs::read(&index_path).unwrap();
1101        let app = test::init_service(project_app!(state.clone())).await;
1102
1103        let response = test::call_service(
1104            &app,
1105            test::TestRequest::post()
1106                .uri("/projects/migrations/legacy/dry-run")
1107                .set_json(serde_json::json!({
1108                    "sessions": [{
1109                        "session_id": "canonical-exact",
1110                        "workspace_path": alias_child.join("..")
1111                    }]
1112                }))
1113                .to_request(),
1114        )
1115        .await;
1116        assert_eq!(response.status(), StatusCode::OK);
1117        let report: LegacyProjectDryRunReport = test::read_body_json(response).await;
1118        assert_eq!(report.assignments.len(), 1);
1119        assert_eq!(report.assignments[0].session_id, "canonical-exact");
1120        assert_eq!(report.assignments[0].project_id, project.id);
1121        assert_eq!(
1122            report.assignments[0].basis,
1123            LegacyProjectMatchBasis::ExactCanonicalBinding
1124        );
1125        assert!(report.suggestions.is_empty());
1126        assert!(report.unassigned.is_empty());
1127
1128        assert_eq!(state.project_store.get(&project.id).unwrap(), before);
1129        assert_eq!(std::fs::read(manifest_path).unwrap(), manifest_bytes);
1130        assert_eq!(std::fs::read(index_path).unwrap(), index_bytes);
1131    }
1132
1133    #[actix_web::test]
1134    async fn legacy_dry_run_matches_linked_worktree_by_derived_git_common_dir() {
1135        let (dir, state) = app_state().await;
1136        let repository = dir.path().join("main-repository");
1137        let linked_worktree = dir.path().join("linked-worktree");
1138        initialize_git_repository(&repository);
1139        let linked_arg = linked_worktree.to_string_lossy().into_owned();
1140        run_git(
1141            &repository,
1142            &["worktree", "add", "-q", "-b", "linked-match", &linked_arg],
1143        );
1144        let project = state
1145            .project_store
1146            .create_with_project_path("Git match", None, repository.to_string_lossy(), Vec::new())
1147            .unwrap();
1148        let app = test::init_service(project_app!(state.clone())).await;
1149
1150        let response = test::call_service(
1151            &app,
1152            test::TestRequest::post()
1153                .uri("/projects/migrations/legacy/dry-run")
1154                .set_json(serde_json::json!({
1155                    "sessions": [{
1156                        "session_id": "linked-session",
1157                        "workspace_path": linked_worktree
1158                    }]
1159                }))
1160                .to_request(),
1161        )
1162        .await;
1163        assert_eq!(response.status(), StatusCode::OK);
1164        let report: LegacyProjectDryRunReport = test::read_body_json(response).await;
1165        assert_eq!(report.assignments.len(), 1);
1166        assert_eq!(report.assignments[0].session_id, "linked-session");
1167        assert_eq!(report.assignments[0].project_id, project.id);
1168        assert_eq!(
1169            report.assignments[0].basis,
1170            LegacyProjectMatchBasis::GitCommonDir
1171        );
1172        assert!(report.suggestions.is_empty());
1173        assert!(report.unassigned.is_empty());
1174    }
1175
1176    #[actix_web::test]
1177    async fn legacy_dry_run_groups_git_worktrees_with_derived_memory_keys() {
1178        let (dir, state) = app_state().await;
1179        let repository = dir.path().join("group-repository");
1180        let linked_worktree = dir.path().join("group-worktree");
1181        initialize_git_repository(&repository);
1182        let linked_arg = linked_worktree.to_string_lossy().into_owned();
1183        run_git(
1184            &repository,
1185            &["worktree", "add", "-q", "-b", "linked-group", &linked_arg],
1186        );
1187        let expected_keys = [
1188            project_key_from_path(&repository),
1189            project_key_from_path(&linked_worktree),
1190        ]
1191        .into_iter()
1192        .collect::<BTreeSet<_>>();
1193        let app = test::init_service(project_app!(state)).await;
1194
1195        let response = test::call_service(
1196            &app,
1197            test::TestRequest::post()
1198                .uri("/projects/migrations/legacy/dry-run")
1199                .set_json(serde_json::json!({
1200                    "sessions": [
1201                        {
1202                            "session_id": "main-session",
1203                            "workspace_path": repository
1204                        },
1205                        {
1206                            "session_id": "worktree-session",
1207                            "workspace_path": linked_worktree
1208                        }
1209                    ]
1210                }))
1211                .to_request(),
1212        )
1213        .await;
1214        assert_eq!(response.status(), StatusCode::OK);
1215        let report: LegacyProjectDryRunReport = test::read_body_json(response).await;
1216        assert!(report.assignments.is_empty());
1217        assert_eq!(report.suggestions.len(), 1);
1218        assert_eq!(
1219            report.suggestions[0].basis,
1220            LegacyProjectMatchBasis::GitCommonDir
1221        );
1222        assert_eq!(
1223            report.suggestions[0]
1224                .session_ids
1225                .iter()
1226                .cloned()
1227                .collect::<BTreeSet<_>>(),
1228            ["main-session".to_string(), "worktree-session".to_string()]
1229                .into_iter()
1230                .collect()
1231        );
1232        assert_eq!(
1233            report.suggestions[0]
1234                .legacy_project_keys
1235                .iter()
1236                .cloned()
1237                .collect::<BTreeSet<_>>(),
1238            expected_keys
1239        );
1240        assert!(report.unassigned.is_empty());
1241    }
1242
1243    #[actix_web::test]
1244    async fn legacy_dry_run_keeps_all_explicit_evidence_authoritative() {
1245        let (dir, state) = app_state().await;
1246        let workspace = dir.path().join("explicit-workspace");
1247        std::fs::create_dir_all(&workspace).unwrap();
1248        let workspace = workspace.to_string_lossy().into_owned();
1249        let app = test::init_service(project_app!(state)).await;
1250
1251        let response = test::call_service(
1252            &app,
1253            test::TestRequest::post()
1254                .uri("/projects/migrations/legacy/dry-run")
1255                .set_json(serde_json::json!({
1256                    "sessions": [
1257                        {
1258                            "session_id": "explicit-a",
1259                            "workspace_path": &workspace,
1260                            "canonical_path": "/caller/canonical-a",
1261                            "git_common_dir": "/caller/shared/.git",
1262                            "legacy_project_keys": ["caller-key-a"]
1263                        },
1264                        {
1265                            "session_id": "explicit-b",
1266                            "workspace_path": &workspace,
1267                            "canonical_path": "/caller/canonical-b",
1268                            "git_common_dir": "/caller/shared/.git",
1269                            "legacy_project_keys": ["caller-key-b"]
1270                        }
1271                    ]
1272                }))
1273                .to_request(),
1274        )
1275        .await;
1276        assert_eq!(response.status(), StatusCode::OK);
1277        let report: LegacyProjectDryRunReport = test::read_body_json(response).await;
1278        assert!(report.assignments.is_empty());
1279        assert_eq!(report.suggestions.len(), 1);
1280        assert_eq!(
1281            report.suggestions[0].basis,
1282            LegacyProjectMatchBasis::GitCommonDir
1283        );
1284        assert_eq!(
1285            report.suggestions[0].legacy_project_keys,
1286            vec!["caller-key-a".to_string(), "caller-key-b".to_string()]
1287        );
1288        assert!(report.unassigned.is_empty());
1289        assert!(report.diagnostics.is_empty());
1290    }
1291
1292    #[actix_web::test]
1293    async fn legacy_dry_run_skips_missing_workspace_enrichment_with_diagnostics() {
1294        let (dir, state) = app_state().await;
1295        let missing = dir.path().join("missing-workspace");
1296        let inputs = vec![LegacySessionProjectInput {
1297            session_id: "missing-session".to_string(),
1298            workspace_path: Some(missing.to_string_lossy().into_owned()),
1299            canonical_path: None,
1300            git_common_dir: None,
1301            legacy_project_keys: Vec::new(),
1302        }];
1303        let (enriched, diagnostics) = enrich_legacy_dry_run_sessions(&inputs);
1304        assert!(enriched[0].canonical_path.is_none());
1305        assert!(enriched[0].git_common_dir.is_none());
1306        assert!(enriched[0].legacy_project_keys.is_empty());
1307        assert!(diagnostics.iter().any(|diagnostic| {
1308            diagnostic.contains("missing-session")
1309                && diagnostic.contains("legacy_project_keys")
1310                && diagnostic.contains("could not be canonicalized")
1311        }));
1312        let app = test::init_service(project_app!(state)).await;
1313
1314        let response = test::call_service(
1315            &app,
1316            test::TestRequest::post()
1317                .uri("/projects/migrations/legacy/dry-run")
1318                .set_json(serde_json::json!({
1319                    "sessions": [
1320                        {
1321                            "session_id": "missing-session",
1322                            "workspace_path": missing
1323                        },
1324                        {
1325                            "session_id": "absent-session"
1326                        }
1327                    ]
1328                }))
1329                .to_request(),
1330        )
1331        .await;
1332        assert_eq!(response.status(), StatusCode::OK);
1333        let report: LegacyProjectDryRunReport = test::read_body_json(response).await;
1334        assert!(report.assignments.is_empty());
1335        assert!(report.suggestions.is_empty());
1336        assert_eq!(report.unassigned.len(), 2);
1337        assert!(report
1338            .diagnostics
1339            .iter()
1340            .any(|diagnostic| diagnostic.contains("missing-session")));
1341        assert!(report
1342            .diagnostics
1343            .iter()
1344            .any(|diagnostic| diagnostic.contains("absent-session")));
1345    }
1346
1347    #[cfg(unix)]
1348    #[actix_web::test]
1349    async fn legacy_dry_run_skips_unreadable_workspace_enrichment() {
1350        use std::os::unix::fs::PermissionsExt;
1351
1352        let dir = tempfile::tempdir().unwrap();
1353        let guarded = dir.path().join("guarded");
1354        let workspace = guarded.join("workspace");
1355        std::fs::create_dir_all(&workspace).unwrap();
1356        let original_permissions = std::fs::metadata(&guarded).unwrap().permissions();
1357        let mut blocked_permissions = original_permissions.clone();
1358        blocked_permissions.set_mode(0o000);
1359        std::fs::set_permissions(&guarded, blocked_permissions).unwrap();
1360
1361        if std::fs::read_dir(&workspace).is_ok() {
1362            std::fs::set_permissions(&guarded, original_permissions).unwrap();
1363            return;
1364        }
1365        let inputs = vec![LegacySessionProjectInput {
1366            session_id: "unreadable-session".to_string(),
1367            workspace_path: Some(workspace.to_string_lossy().into_owned()),
1368            canonical_path: None,
1369            git_common_dir: None,
1370            legacy_project_keys: Vec::new(),
1371        }];
1372        let (enriched, diagnostics) = enrich_legacy_dry_run_sessions(&inputs);
1373        std::fs::set_permissions(&guarded, original_permissions).unwrap();
1374
1375        assert!(enriched[0].canonical_path.is_none());
1376        assert!(enriched[0].git_common_dir.is_none());
1377        assert!(enriched[0].legacy_project_keys.is_empty());
1378        assert!(diagnostics.iter().any(|diagnostic| {
1379            diagnostic.contains("unreadable-session") && diagnostic.contains("could not enrich")
1380        }));
1381    }
1382
1383    #[actix_web::test]
1384    async fn unarchive_route_is_cas_guarded_replayable_and_preserves_ownership() {
1385        let (dir, state) = app_state().await;
1386        let app = test::init_service(project_app!(state.clone())).await;
1387        let project_path = dir.path().join("zenith");
1388        let workspace_path = dir.path().join("worktree");
1389        std::fs::create_dir_all(&project_path).unwrap();
1390        std::fs::create_dir_all(&workspace_path).unwrap();
1391
1392        let project = state
1393            .project_store
1394            .create_with_project_path(
1395                "Zenith",
1396                Some("Restore me".to_string()),
1397                project_path.to_string_lossy(),
1398                vec![WorkspaceBinding {
1399                    path: workspace_path.to_string_lossy().into_owned(),
1400                    label: Some("Issue worktree".to_string()),
1401                    git_common_dir: None,
1402                }],
1403            )
1404            .unwrap();
1405        let project = state
1406            .project_store
1407            .update(&project.id, project.revision, |manifest| {
1408                manifest
1409                    .legacy_project_keys
1410                    .push("legacy-zenith".to_string());
1411                Ok(())
1412            })
1413            .unwrap();
1414        let archived = state
1415            .project_store
1416            .archive(&project.id, project.revision)
1417            .unwrap();
1418
1419        let session_id = "project-unarchive-session";
1420        let mut session = bamboo_agent_core::Session::new(session_id, "test-model");
1421        session.set_project_id_meta(archived.id.to_string());
1422        session.set_workspace_path_meta(archived.workspace_bindings[0].path.clone());
1423        state.storage.save_session(&session).await.unwrap();
1424
1425        let missing = test::call_service(
1426            &app,
1427            test::TestRequest::post()
1428                .uri(&format!("/projects/{}/unarchive", archived.id))
1429                .to_request(),
1430        )
1431        .await;
1432        assert_eq!(missing.status(), StatusCode::PRECONDITION_REQUIRED);
1433
1434        let stale = test::call_service(
1435            &app,
1436            test::TestRequest::post()
1437                .uri(&format!("/projects/{}/unarchive", archived.id))
1438                .insert_header((header::IF_MATCH, format!("\"{}\"", archived.revision + 1)))
1439                .to_request(),
1440        )
1441        .await;
1442        assert_eq!(stale.status(), StatusCode::PRECONDITION_FAILED);
1443        assert_eq!(
1444            state.project_store.get(&archived.id).unwrap(),
1445            archived,
1446            "stale restore must not mutate the Project"
1447        );
1448
1449        let journal_cursor = state.account_sink.latest_seq();
1450        let mut feed = state.account_sink.subscribe();
1451        let response = test::call_service(
1452            &app,
1453            test::TestRequest::post()
1454                .uri(&format!("/projects/{}/unarchive", archived.id))
1455                .insert_header((header::IF_MATCH, format!("\"{}\"", archived.revision)))
1456                .to_request(),
1457        )
1458        .await;
1459        assert_eq!(response.status(), StatusCode::OK);
1460        assert_eq!(
1461            response.headers().get(header::ETAG).unwrap(),
1462            format!("\"{}\"", archived.revision + 1).as_str()
1463        );
1464        let restored: ProjectManifest = test::read_body_json(response).await;
1465        assert_eq!(restored.status, ProjectStatus::Active);
1466        assert_eq!(restored.revision, archived.revision + 1);
1467        assert_eq!(restored.id, archived.id);
1468        assert_eq!(restored.project_path, archived.project_path);
1469        assert_eq!(restored.project_path_status, archived.project_path_status);
1470        assert_eq!(restored.workspace_bindings, archived.workspace_bindings);
1471        assert_eq!(restored.legacy_project_keys, archived.legacy_project_keys);
1472        assert_eq!(restored.resource_revision, archived.resource_revision);
1473        assert_eq!(restored.created_at, archived.created_at);
1474
1475        let event = tokio::time::timeout(std::time::Duration::from_secs(1), feed.recv())
1476            .await
1477            .expect("ProjectUpdated delivery")
1478            .expect("account feed event");
1479        assert!(matches!(
1480            &event.event,
1481            AgentEvent::ProjectUpdated {
1482                project_id,
1483                revision,
1484            } if project_id == restored.id.as_str() && *revision == restored.revision
1485        ));
1486        let replay = bamboo_engine::events::journal::read_since(
1487            state.account_sink.events_dir(),
1488            journal_cursor,
1489        )
1490        .expect("journal replay");
1491        assert!(replay.iter().any(|change| matches!(
1492            &change.event,
1493            AgentEvent::ProjectUpdated {
1494                project_id,
1495                revision,
1496            } if project_id == restored.id.as_str() && *revision == restored.revision
1497        )));
1498
1499        let persisted_session = state
1500            .storage
1501            .load_session(session_id)
1502            .await
1503            .unwrap()
1504            .expect("persisted session");
1505        assert_eq!(
1506            persisted_session.project_id_meta().as_deref(),
1507            Some(restored.id.as_str())
1508        );
1509        assert_eq!(
1510            persisted_session.workspace_path_meta(),
1511            Some(restored.workspace_bindings[0].path.clone())
1512        );
1513
1514        let repeated = test::call_service(
1515            &app,
1516            test::TestRequest::post()
1517                .uri(&format!("/projects/{}/unarchive", restored.id))
1518                .insert_header((header::IF_MATCH, format!("\"{}\"", restored.revision)))
1519                .to_request(),
1520        )
1521        .await;
1522        assert_eq!(repeated.status(), StatusCode::CONFLICT);
1523        let body: Value = test::read_body_json(repeated).await;
1524        assert_eq!(body["error"]["code"], "project_not_archived");
1525        assert_eq!(body["project_id"], restored.id.to_string());
1526        assert_eq!(
1527            state.project_store.get(&restored.id).unwrap(),
1528            restored,
1529            "repeated restore must not create stale optimistic state"
1530        );
1531        assert!(
1532            tokio::time::timeout(std::time::Duration::from_millis(50), feed.recv())
1533                .await
1534                .is_err(),
1535            "rejected restore must not publish ProjectUpdated"
1536        );
1537    }
1538
1539    #[actix_web::test]
1540    async fn workspace_binding_routes_are_cas_guarded_and_conflict_safe() {
1541        let (dir, state) = app_state().await;
1542        let app = test::init_service(project_app!(state.clone())).await;
1543        let workspace = dir.path().join("workspace");
1544        std::fs::create_dir_all(&workspace).unwrap();
1545        let first = state.project_store.create("First", None).unwrap();
1546        let second = state.project_store.create("Second", None).unwrap();
1547
1548        let bind = test::call_service(
1549            &app,
1550            test::TestRequest::post()
1551                .uri(&format!("/projects/{}/workspaces", first.id))
1552                .insert_header((header::IF_MATCH, format!("\"{}\"", first.revision)))
1553                .set_json(serde_json::json!({"path": workspace}))
1554                .to_request(),
1555        )
1556        .await;
1557        assert_eq!(bind.status(), StatusCode::OK);
1558        let first_bound: ProjectManifest = test::read_body_json(bind).await;
1559        assert_eq!(first_bound.workspace_bindings.len(), 1);
1560
1561        let conflict = test::call_service(
1562            &app,
1563            test::TestRequest::post()
1564                .uri(&format!("/projects/{}/workspaces", second.id))
1565                .insert_header((header::IF_MATCH, format!("\"{}\"", second.revision)))
1566                .set_json(serde_json::json!({"path": workspace}))
1567                .to_request(),
1568        )
1569        .await;
1570        assert_eq!(conflict.status(), StatusCode::CONFLICT);
1571        assert!(state
1572            .project_store
1573            .get(&second.id)
1574            .unwrap()
1575            .workspace_bindings
1576            .is_empty());
1577
1578        let stale_unbind = test::call_service(
1579            &app,
1580            test::TestRequest::delete()
1581                .uri(&format!("/projects/{}/workspaces", first.id))
1582                .insert_header((header::IF_MATCH, "\"1\""))
1583                .set_json(serde_json::json!({"path": workspace}))
1584                .to_request(),
1585        )
1586        .await;
1587        assert_eq!(stale_unbind.status(), StatusCode::PRECONDITION_FAILED);
1588
1589        let unbind = test::call_service(
1590            &app,
1591            test::TestRequest::delete()
1592                .uri(&format!("/projects/{}/workspaces", first.id))
1593                .insert_header((header::IF_MATCH, format!("\"{}\"", first_bound.revision)))
1594                .set_json(serde_json::json!({"path": workspace}))
1595                .to_request(),
1596        )
1597        .await;
1598        assert_eq!(unbind.status(), StatusCode::OK);
1599    }
1600}