1use 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 pub project_path: String,
31 #[serde(default)]
32 pub workspace_bindings: Vec<WorkspaceBinding>,
33}
34
35fn 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 #[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 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}