//! Model-authored mission PLANS — the phase list — and whether a human //! accepted them. //! //! See `migrations/0070_mission_plan_proposals.sql` for why a proposal is //! persisted rather than applied on arrival. use crate::DbError; use serde_json::Value; use sqlx::PgPool; use time::OffsetDateTime; use uuid::Uuid; #[derive(Debug, Clone, serde::Serialize)] pub struct MissionPlanProposal { pub id: Uuid, pub mission_id: Uuid, pub plan: Value, pub author_model: String, pub status: String, pub note: Option, #[serde(with = "time::serde::rfc3339")] pub created_at: OffsetDateTime, #[serde(with = "time::serde::rfc3339::option")] pub decided_at: Option, } pub async fn insert( pool: &PgPool, id: Uuid, mission_id: Uuid, workspace_id: Uuid, plan: &Value, author_model: &str, ) -> Result<(), DbError> { sqlx::query( "INSERT INTO mission_plan_proposals (id, mission_id, workspace_id, plan, author_model) VALUES ($1, $2, $3, $4, $5)", ) .bind(id) .bind(mission_id) .bind(workspace_id) .bind(plan) .bind(author_model) .execute(pool) .await?; Ok(()) } /// Every proposal for a mission, newest first. Rejected ones are included on /// purpose: what a human turned down is the only record of what the planner /// gets wrong. pub async fn list( pool: &PgPool, mission_id: Uuid, workspace_id: Uuid, ) -> Result, DbError> { let rows = sqlx::query_as::<_, (Uuid, Uuid, Value, String, String, Option, OffsetDateTime, Option)>( "SELECT id, mission_id, plan, author_model, status, note, created_at, decided_at FROM mission_plan_proposals WHERE mission_id = $1 AND workspace_id = $2 ORDER BY created_at DESC", ) .bind(mission_id) .bind(workspace_id) .fetch_all(pool) .await?; Ok(rows .into_iter() .map( |(id, mission_id, plan, author_model, status, note, created_at, decided_at)| { MissionPlanProposal { id, mission_id, plan, author_model, status, note, created_at, decided_at, } }, ) .collect()) } pub async fn get( pool: &PgPool, id: Uuid, workspace_id: Uuid, ) -> Result, DbError> { let row = sqlx::query_as::<_, (Uuid, Uuid, Value, String, String, Option, OffsetDateTime, Option)>( "SELECT id, mission_id, plan, author_model, status, note, created_at, decided_at FROM mission_plan_proposals WHERE id = $1 AND workspace_id = $2", ) .bind(id) .bind(workspace_id) .fetch_optional(pool) .await?; Ok(row.map( |(id, mission_id, plan, author_model, status, note, created_at, decided_at)| { MissionPlanProposal { id, mission_id, plan, author_model, status, note, created_at, decided_at, } }, )) } /// Approve a plan AND write its phases onto the mission, atomically. /// /// One transaction, for the reason `mission_team_proposals::approve_and_apply` /// documents: a two-statement version left a proposal marked `approved` against /// a mission that never received it, and the partial unique index then makes /// that state permanent. /// /// The mission's existing phases are REPLACED. A plan is an answer to "what is /// this mission", not an addition to the recipe's answer — merging the two would /// produce a phase list neither the model nor the recipe author intended. Only a /// draft mission is eligible (checked by the caller), so nothing in flight is /// discarded. #[allow(clippy::too_many_arguments)] pub async fn approve_and_apply( pool: &PgPool, id: Uuid, mission_id: Uuid, workspace_id: Uuid, phases: &[(String, i32, Value)], note: Option<&str>, decided_by: Option, ) -> Result { let mut tx = pool.begin().await?; let claimed = sqlx::query( "UPDATE mission_plan_proposals SET status = 'approved', note = $3, decided_at = now(), decided_by = $4 WHERE id = $1 AND workspace_id = $2 AND status = 'proposed'", ) .bind(id) .bind(workspace_id) .bind(note) .bind(decided_by) .execute(&mut *tx) .await? .rows_affected(); if claimed != 1 { tx.rollback().await?; return Ok(false); } // Scoped by workspace on the mission, so a proposal cannot rewrite the // phases of a mission in another workspace even if its own row were forged. let owned: i64 = sqlx::query_scalar( "SELECT count(*) FROM missions WHERE id = $1 AND workspace_id = $2", ) .bind(mission_id) .bind(workspace_id) .fetch_one(&mut *tx) .await?; if owned != 1 { tx.rollback().await?; return Err(DbError::NotFound); } sqlx::query("DELETE FROM mission_phases WHERE mission_id = $1") .bind(mission_id) .execute(&mut *tx) .await?; for (kind, order_idx, config) in phases { // `done_when` is PROMOTED out of the config into its column, exactly as // `missions::create` does. The evaluator sweep filters on the column in // SQL on every tick — a plan whose condition stayed in the JSONB blob // would be stored, rendered, and never judged, which is the same shape // as the unread `task` this whole registry exists because of. let done_when = config .get("done_when") .and_then(|v| v.as_str()) .map(str::trim) .filter(|s| !s.is_empty()); sqlx::query( "INSERT INTO mission_phases (id, mission_id, kind, order_idx, status, config, done_when, max_iterations) VALUES ($1, $2, $3, $4, 'pending', $5, $6, 1)", ) .bind(Uuid::now_v7()) .bind(mission_id) .bind(kind) .bind(order_idx) .bind(config) .bind(done_when) .execute(&mut *tx) .await?; } tx.commit().await?; Ok(true) } /// Record a decision. Only a `proposed` row may be decided, so approving twice /// — a double-click, a retried request — cannot re-apply a roster to a mission /// that has since moved on. Returns whether this call was the one that decided. pub async fn decide( pool: &PgPool, id: Uuid, workspace_id: Uuid, status: &str, note: Option<&str>, decided_by: Option, ) -> Result { let done = sqlx::query( "UPDATE mission_plan_proposals SET status = $3, note = $4, decided_at = now(), decided_by = $5 WHERE id = $1 AND workspace_id = $2 AND status = 'proposed'", ) .bind(id) .bind(workspace_id) .bind(status) .bind(note) .bind(decided_by) .execute(pool) .await? .rows_affected(); Ok(done == 1) }