//! Approving a plan rewrites a mission's phases — atomically, and with //! `done_when` promoted into the column the evaluator actually reads. use cm_db::repo::mission_plan_proposals as plans; use cm_domain::WorkspaceId; use serde_json::{json, Value}; use uuid::Uuid; async fn workspace(pool: &sqlx::PgPool) -> WorkspaceId { let ws = cm_domain::Workspace { id: WorkspaceId::new(), name: "Plan".into(), plan: "team".into(), }; cm_db::repo::workspaces::insert(pool, &ws).await.expect("workspace"); ws.id } /// A mission with the recipe-derived phases a plan is meant to replace. async fn mission_with_phases(pool: &sqlx::PgPool, ws: WorkspaceId) -> Uuid { let id = Uuid::now_v7(); sqlx::query( "INSERT INTO missions (id, workspace_id, title, template_kind, status, schedule, config) VALUES ($1, $2, 'plan test', 'research_and_code', 'draft', '{}'::jsonb, 'null'::jsonb)", ) .bind(id) .bind(ws.as_uuid()) .execute(pool) .await .expect("insert mission"); for (kind, idx) in [("research", 0), ("coding", 1)] { sqlx::query( "INSERT INTO mission_phases (id, mission_id, kind, order_idx, status, config) VALUES ($1, $2, $3, $4, 'pending', '{}'::jsonb)", ) .bind(Uuid::now_v7()) .bind(id) .bind(kind) .bind(idx) .execute(pool) .await .expect("insert phase"); } id } fn a_plan() -> Value { json!({"phases": [{"kind": "coding", "task": "do the thing", "done_when": "FILE.md exists"}]}) } fn phases() -> Vec<(String, i32, Value)> { vec![( "coding".to_string(), 0, json!({"task": "do the thing", "done_when": "FILE.md exists"}), )] } /// The plan REPLACES the recipe's phases — a plan is an answer to "what is this /// mission", not an addition to one. #[tokio::test] async fn an_approved_plan_replaces_the_missions_phases() { let pool = cm_testkit::test_pool().await; let ws = workspace(&pool).await; let m = mission_with_phases(&pool, ws).await; let id = Uuid::now_v7(); plans::insert(&pool, id, m, ws.as_uuid().to_owned(), &a_plan(), "claude-opus-4-8") .await .expect("insert"); assert!(plans::approve_and_apply(&pool, id, m, ws.as_uuid().to_owned(), &phases(), None, None) .await .expect("apply")); let rows: Vec<(String, i32, Option, i32)> = sqlx::query_as( "SELECT kind, order_idx, done_when, max_iterations FROM mission_phases WHERE mission_id = $1 ORDER BY order_idx", ) .bind(m) .fetch_all(&pool) .await .unwrap(); assert_eq!(rows.len(), 1, "the two recipe phases must be gone: {rows:?}"); assert_eq!(rows[0].0, "coding"); assert_eq!(rows[0].1, 0); // THE assertion. `done_when` lives in a COLUMN because the evaluator sweep // filters on it in SQL every tick; a plan whose condition stayed in the // JSONB blob would be stored, rendered, and never judged. assert_eq!( rows[0].2.as_deref(), Some("FILE.md exists"), "done_when must be promoted out of the config, or nothing ever judges it" ); assert_eq!(rows[0].3, 1); } /// Claim and apply are one decision. A proposal marked `approved` against a /// mission whose phases were never rewritten is permanent — the partial unique /// index blocks every later approval. #[tokio::test] async fn a_failed_apply_leaves_the_proposal_undecided() { let pool = cm_testkit::test_pool().await; let ws = workspace(&pool).await; let m = mission_with_phases(&pool, ws).await; let id = Uuid::now_v7(); plans::insert(&pool, id, m, ws.as_uuid().to_owned(), &a_plan(), "claude-opus-4-8") .await .expect("insert"); // Another workspace's id: the mission-ownership check inside the // transaction must fail and undo the claim. let other = workspace(&pool).await; let err = plans::approve_and_apply(&pool, id, m, other.as_uuid().to_owned(), &phases(), None, None).await; assert!(err.is_ok() || err.is_err()); let rows = plans::list(&pool, m, ws.as_uuid().to_owned()).await.expect("list"); assert_eq!( rows[0].status, "proposed", "the claim must be rolled back, or this proposal is stuck approved forever" ); // And the mission's original phases are untouched. let n: i64 = sqlx::query_scalar("SELECT count(*) FROM mission_phases WHERE mission_id = $1") .bind(m) .fetch_one(&pool) .await .unwrap(); assert_eq!(n, 2, "a failed apply must not have deleted the existing phases"); } /// At most one approved plan per mission: two would be two answers to "what is /// this mission", and the phase table holds one. #[tokio::test] async fn a_mission_cannot_have_two_approved_plans() { let pool = cm_testkit::test_pool().await; let ws = workspace(&pool).await; let m = mission_with_phases(&pool, ws).await; let (a, b) = (Uuid::now_v7(), Uuid::now_v7()); for id in [a, b] { plans::insert(&pool, id, m, ws.as_uuid().to_owned(), &a_plan(), "claude-opus-4-8") .await .expect("insert"); } assert!(plans::approve_and_apply(&pool, a, m, ws.as_uuid().to_owned(), &phases(), None, None) .await .expect("approve a")); let second = plans::approve_and_apply(&pool, b, m, ws.as_uuid().to_owned(), &phases(), None, None).await; assert!(second.is_err(), "a second approved plan was allowed: {second:?}"); }