//! `/api/missions/{id}/plan-proposals` — let a model author the phases. //! //! W1 / #13, and the sibling of [`crate::routes::mission_roster`]: that one has //! a model size the team, this one has it decide what the work is. Same three //! verbs and the same rule — propose and decide are separate, because only the //! second one changes a mission. //! //! The model is handed two lists it may not depart from: the phase kinds //! `phase_runner` dispatches on, and the config keys `phase_config` says have //! readers. Both are enforced again on the way in, so a plan cannot describe //! work this platform will accept and then not do. use axum::extract::{Path, State}; use axum::Json; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use uuid::Uuid; use crate::mission_plan::{Plan, MAX_PHASES, PLANNABLE_KINDS}; use crate::{ApiError, AppState, Authed}; const PLANNER_MODEL: &str = "claude-opus-4-8"; /// What the repository actually contains, for the planner's prompt. /// /// Names were not enough. Given the root listing alone, the planner wrote /// "optimise the hot path" for a crate whose hot path is /// `add(a: i64, b: i64) -> i64` — a mission that was unachievable from the /// moment it was written, and that nothing discovered until an agent had built a /// benchmark harness to measure an integer addition. /// /// Read from the FORGE, not a checkout: at proposal time the mission is still a /// draft and `ensure_checkout` has not run, so there is nothing on disk. Every /// failure degrades to a STATED absence — a planner told "the listing could not /// be read" can hedge; one told nothing assumes. async fn repo_digest(pool: &sqlx::PgPool, mission_id: uuid::Uuid) -> String { let row: Option<(Option, Option, Option)> = sqlx::query_as( "SELECT r.owner, r.name, r.default_branch FROM missions m JOIN repos r ON r.id = m.repo_id WHERE m.id = $1", ) .bind(mission_id) .fetch_optional(pool) .await .ok() .flatten(); let Some((Some(owner), Some(name), branch)) = row else { return "(this mission has no repository)".to_string(); }; let branch = branch.unwrap_or_else(|| "main".to_string()); // Distinguish "no credential" from "the forge said no". Both used to // arrive as the same "(could not be read)" string, so an unconfigured // deployment looked identical to a private repo — and the planner, told // only that the read failed, cannot say which. let token = std::env::var("GITEA_TOKEN").unwrap_or_default(); let unauthenticated = token.trim().is_empty(); let Ok(client) = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(20)) .build() else { return "(the repository could not be read)".to_string(); }; let auth = |r: reqwest::RequestBuilder| { if token.trim().is_empty() { r } else { r.header("Authorization", format!("token {token}")) } }; // The whole tree in one call, so "does this repo have benches/" is a fact // rather than an inference from the root. let tree_url = format!( "https://git.redclaw.dev/api/v1/repos/{owner}/{name}/git/trees/{branch}?recursive=true&per_page=1000" ); let tree: serde_json::Value = match auth(client.get(&tree_url)).send().await { Ok(r) if r.status().is_success() => r.json().await.unwrap_or_default(), _ if unauthenticated => { return "(the repository tree could not be read: GITEA_TOKEN is unset, \ so this read was unauthenticated)" .to_string() } _ => return "(the repository tree could not be read)".to_string(), }; let entries: Vec = tree .get("tree") .and_then(|t| t.as_array()) .map(|items| { items .iter() .filter(|e| e.get("type").and_then(|v| v.as_str()) == Some("blob")) .filter_map(|e| { Some(crate::repo_digest::FileEntry { path: e.get("path")?.as_str()?.to_string(), size: e.get("size").and_then(|v| v.as_u64()).unwrap_or(0) as usize, }) }) .collect() }) .unwrap_or_default(); // Fetch in priority order until the budget is spent. Requested serially and // capped: this runs inside one API request, and a repo with 500 useful files // must not turn a proposal into 500 round trips. let mut fetched: Vec<(String, String)> = Vec::new(); let mut spent = 0usize; for e in crate::repo_digest::priority(&entries).into_iter().take(40) { if spent >= crate::repo_digest::CONTENT_BUDGET { break; } let raw = format!( "https://git.redclaw.dev/api/v1/repos/{owner}/{name}/raw/{}?ref={branch}", e.path ); if let Ok(r) = auth(client.get(&raw)).send().await { if r.status().is_success() { if let Ok(text) = r.text().await { spent += text.len().min(crate::repo_digest::PER_FILE_CAP); fetched.push((e.path.clone(), text)); } } } } crate::repo_digest::render(&entries, &crate::repo_digest::fit(fetched)) } const PLAN_SYSTEM: &str = "You decide what ONE software mission actually does — its phases, in order. \ Each phase is a full agent run against the same repository checkout: the next phase sees the tree the \ previous one left. They run SEQUENTIALLY, so phases are expensive and a handoff loses context at every \ step.\n\n\ Propose the FEWEST phases that genuinely need to be separate. ONE phase is usually the right answer, and \ is always the right answer for a self-contained change: splitting one change into plan → implement → \ test is a documented anti-pattern, not thoroughness — a single agent doing all three in one pass keeps \ the context that makes the later steps good. A second phase earns its place only when it depends on \ something the first phase could not have known when it started.\n\n\ Every phase needs a `task`: the specific instruction for THAT phase, not a restatement of the mission. \ An agent receives the mission description plus its own task, so a vague task means an agent guessing \ which part of the mission is its share.\n\n\ `done_when` is judged afterwards by a separate model reading the repository, so write it as something \ observable in the tree — a file that exists, a suite that passes — never as an intention. \ `done_when_check` is a SHELL COMMAND that must exit 0; it is enforced while the agent still works, so \ prefer it when the condition is mechanical. Set `allow_empty` true only for a phase whose job is to \ verify rather than to change files.\n\n\ ALWAYS respond with STRICT JSON ONLY, no prose and no markdown: \ {\"phases\":[{\"kind\":\"coding\",\"task\":\"...\",\"done_when\":null|\"...\",\ \"done_when_check\":null|\"...\",\"allow_empty\":null|true|false}]}"; #[derive(Debug, Serialize)] pub struct PlanProposalResponse { pub id: Uuid, pub plan: Value, pub author_model: String, pub status: String, } /// `POST /api/missions/{id}/plan-proposals` — ask the model for a phase plan. pub async fn suggest( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { let ws = user.workspace_id; let mission = cm_db::repo::missions::get(&state.pool, id, ws.as_uuid()) .await .map_err(|_| ApiError::Internal)? .ok_or(ApiError::NotFound)?; let prompt = format!( "MISSION: {}\n\nDESCRIPTION:\n{}\n\n=== THE REPOSITORY ===\n{}\n=== END REPOSITORY \ ===\n\nPlan for the repository as it ACTUALLY IS, not as the description implies it \ might be. If the work needs something absent — a benchmark harness, a test suite, a \ config file — the phase that needs it must CREATE it, and its task must say so. If the \ description asks for something this code cannot support (optimising a function with \ nothing to optimise, testing a module that does not exist), say so in the task text and \ plan the phase that would establish the truth, rather than a phase that must fail.\n\n\ NOTE: a mission agent has NO package-registry access — it cannot add dependencies. A \ phase needing tooling must build it from the standard library or from what is already \ vendored here.\n\nPHASE KINDS YOU MAY USE (nothing else runs): {}\nCEILING: \ {MAX_PHASES} phases.\n\nPropose the plan now (JSON only).", mission.title, mission.description.as_deref().unwrap_or("(none)"), repo_digest(&state.pool, id).await, PLANNABLE_KINDS.join(", "), ); // The stored `author_model` is whichever link of the fallback chain // actually answered — see `subscription::complete_with_fallback`. let (raw, author_model) = crate::subscription::complete_with_fallback( &state.runtime, PLAN_SYSTEM, &prompt, PLANNER_MODEL, 2000, false, ) .await .map_err(|e| { eprintln!("mission {id}: plan proposal failed: {e}"); crate::subscription::as_api_error(&e) })?; let parsed: Value = crate::routes::claws::extract_json(&raw).ok_or_else(|| { eprintln!("mission {id}: planner returned no JSON: {raw}"); ApiError::BadRequest })?; let plan: Plan = serde_json::from_value(parsed.clone()).map_err(|e| { eprintln!("mission {id}: planner JSON is not a plan ({e}): {parsed}"); ApiError::BadRequest })?; // Validated BEFORE storing, so a stored proposal is always one that could be // approved — the failure belongs to the model, not to whoever clicks // approve later. if let Err(why) = plan.validate() { eprintln!("mission {id}: planner proposed an unrunnable plan: {why}"); return Err(ApiError::BadRequest); } let pid = Uuid::now_v7(); let stored = serde_json::to_value(&plan).map_err(|_| ApiError::Internal)?; cm_db::repo::mission_plan_proposals::insert( &state.pool, pid, id, ws.as_uuid().to_owned(), &stored, &author_model, ) .await .map_err(|e| { eprintln!("mission {id}: could not store plan proposal: {e}"); ApiError::Internal })?; eprintln!( "mission_plan: mission {id} — {author_model} proposed {} phase(s): {}", plan.phases.len(), plan.phases .iter() .map(|p| p.kind.as_str()) .collect::>() .join(" → ") ); Ok(Json(PlanProposalResponse { id: pid, plan: stored, author_model, status: "proposed".into(), })) } /// `GET /api/missions/{id}/plan-proposals` pub async fn list( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result>, ApiError> { let rows = cm_db::repo::mission_plan_proposals::list( &state.pool, id, user.workspace_id.as_uuid().to_owned(), ) .await .map_err(|_| ApiError::Internal)?; Ok(Json(rows)) } #[derive(Debug, Deserialize)] pub struct DecideRequest { pub status: String, #[serde(default)] pub note: Option, } /// `POST /api/missions/{id}/plan-proposals/{pid}/decide` /// /// Approving REPLACES the mission's phases. Draft-only: re-planning a mission /// whose phases have started would discard work that already ran, and the phase /// rows are what every downstream sweep keys off. pub async fn decide( State(state): State, Authed(user): Authed, Path((id, pid)): Path<(Uuid, Uuid)>, Json(body): Json, ) -> Result, ApiError> { let ws = user.workspace_id; let proposal = cm_db::repo::mission_plan_proposals::get(&state.pool, pid, ws.as_uuid().to_owned()) .await .map_err(|_| ApiError::Internal)? .ok_or(ApiError::NotFound)?; if proposal.mission_id != id { return Err(ApiError::NotFound); } if body.status == "rejected" { let decided = cm_db::repo::mission_plan_proposals::decide( &state.pool, pid, ws.as_uuid().to_owned(), "rejected", body.note.as_deref(), Some(user.user_id.as_uuid().to_owned()), ) .await .map_err(|_| ApiError::Internal)?; return Ok(Json(json!({ "status": "rejected", "decided": decided }))); } if body.status != "approved" { return Err(ApiError::BadRequest); } let mission = cm_db::repo::missions::get(&state.pool, id, ws.as_uuid()) .await .map_err(|_| ApiError::Internal)? .ok_or(ApiError::NotFound)?; if mission.status != "draft" { return Err(ApiError::Refused(format!( "this mission is {} — a {} can only be approved while it is a draft, \ because approving one rewrites how the mission will run", mission.status, "plan" ))); } let plan: Plan = serde_json::from_value(proposal.plan.clone()).map_err(|e| { eprintln!("mission {id}: stored plan {pid} does not parse ({e})"); ApiError::Internal })?; // Re-validated at approval. The stored plan passed once, but `PLANNABLE_KINDS` // and the config registry are properties of the BUILD — a proposal made // before a deploy could name a kind this build no longer dispatches. if let Err(why) = plan.validate() { eprintln!("mission {id}: plan {pid} is no longer runnable: {why}"); let reason = why.to_string(); let _ = cm_db::repo::mission_plan_proposals::decide( &state.pool, pid, ws.as_uuid().to_owned(), "rejected", Some(&why.to_string()), Some(user.user_id.as_uuid().to_owned()), ) .await; // `Refusal` is already written as human-readable copy — it names the // constraint and why it exists. It was going to stderr only. return Err(ApiError::Refused(format!( "this plan is no longer runnable on the current build, so it was \ rejected: {reason}" ))); } let phases = plan.phases(); let claimed = cm_db::repo::mission_plan_proposals::approve_and_apply( &state.pool, pid, id, ws.as_uuid().to_owned(), &phases, body.note.as_deref(), Some(user.user_id.as_uuid().to_owned()), ) .await .map_err(|e| { eprintln!("mission {id}: could not apply plan {pid}: {e}"); ApiError::Internal })?; if !claimed { return Err(ApiError::BadRequest); } eprintln!( "mission_plan: mission {id} now runs a {}-phase model-authored plan from proposal {pid}", phases.len() ); Ok(Json(json!({ "status": "approved", "phases": phases.iter().map(|(k, i, _)| json!({"kind": k, "order_idx": i})).collect::>(), }))) }