slice 1: missions data model + migration
ci / gates (push) Successful in 6s
ci / rust (push) Failing after 9s
ci / frontend (push) Successful in 25s
ci / e2e (push) Skipped
ci / publish (push) Skipped

Introduce the unified `missions` tier that will replace the current
research_topics + loops split. This slice ships the data model +
backfill + skeleton REST surface; the old wizards keep working in
parallel until Slice 9's big-bang cutover.

Migration 0047 adds:
  - missions              (top-level workflow: template_kind + team +
                           schedule + status + config)
  - mission_phases        (ordered {research|coding|benchmark|
                           security_scan} phases per mission)
  - mission_tasks         (typed units of work, e.g. INT-XX cards,
                           UPSERT-keyed on (phase_id, external_id))
  - mission_artifacts     (MD/PDF/benchmark/security/diff files with
                           a pending queue for the PDF renderer worker)
  - benchmark_snapshots   (before/after pairs per iteration)

Backfill copies existing research_topics + loops rows into the new
tables as one-shot missions with the appropriate template_kind, so
Slice 2's UI can render the full history immediately.

New Rust surface:
  - cm_domain: MissionId, MissionPhaseId, MissionTaskId, MissionArtifactId
  - cm_db::repo::missions: Mission/MissionPhase/MissionTask/
    MissionArtifact structs + insert (txn-wrapped)/get/list/set_status/
    phases_for/set_phase_status/upsert_task/tasks_for/register_artifact/
    artifacts_for/next_pdf_pending/set_pdf_result
  - cm_api::routes::missions: skeleton list/create/get/set_status
    routes registered at /api/missions/*

Follow-up slices layer richer behavior (template dispatch, phase
execution, task parsing, artifact rendering) on this foundation.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-19 11:40:51 -07:00
co-authored by Claude Opus 4.7
parent 68d84bf1ad
commit fbefc67878
8 changed files with 1040 additions and 1 deletions
+165
View File
@@ -0,0 +1,165 @@
//! `/api/missions/*` — the unified workflow surface (Slice 1).
//!
//! This is a skeleton: create/list/get/status only. Slices 48 layer
//! richer behavior on top (template dispatch, phase execution, task
//! parsing, artifact rendering). The old `/api/research/*` +
//! `/api/loops/*` surfaces stay live in parallel until Slice 9.
use axum::{
Json,
extract::{Path, Query, State},
};
use cm_db::repo::missions::{Mission, MissionArtifact, MissionPhase, MissionTask, NewMission, NewMissionPhase};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use crate::{ApiError, AppState, Authed};
// ── Requests ─────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct CreateMissionRequest {
pub title: String,
pub template_kind: String,
pub team_id: Option<Uuid>,
pub team_template_id: Option<Uuid>,
pub repo_id: Option<Uuid>,
#[serde(default = "default_schedule")]
pub schedule: Value,
pub description: Option<String>,
#[serde(default)]
pub config: Value,
#[serde(default)]
pub phases: Vec<PhaseSpec>,
}
fn default_schedule() -> Value {
serde_json::json!({ "kind": "one_shot" })
}
#[derive(Debug, Deserialize)]
pub struct PhaseSpec {
pub kind: String,
pub order_idx: i32,
#[serde(default)]
pub config: Value,
}
#[derive(Debug, Deserialize)]
pub struct ListQuery {
#[serde(default = "default_limit")]
pub limit: i64,
}
fn default_limit() -> i64 {
50
}
#[derive(Debug, Deserialize)]
pub struct SetStatusRequest {
pub status: String,
}
// ── Responses ────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
pub struct MissionDetail {
#[serde(flatten)]
pub mission: Mission,
pub phases: Vec<MissionPhase>,
pub tasks: Vec<MissionTask>,
pub artifacts: Vec<MissionArtifact>,
}
// ── Handlers ─────────────────────────────────────────────────────
pub async fn list(
State(state): State<AppState>,
Authed(user): Authed,
Query(q): Query<ListQuery>,
) -> Result<Json<Vec<Mission>>, ApiError> {
let rows = cm_db::repo::missions::list_by_workspace(
&state.pool,
user.workspace_id.as_uuid(),
q.limit.clamp(1, 500),
)
.await?;
Ok(Json(rows))
}
pub async fn create(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<CreateMissionRequest>,
) -> Result<Json<Mission>, ApiError> {
if body.title.trim().is_empty() {
return Err(ApiError::BadRequest);
}
let new = NewMission {
workspace_id: user.workspace_id.as_uuid(),
title: body.title.trim(),
template_kind: body.template_kind.trim(),
team_id: body.team_id,
team_template_id: body.team_template_id,
repo_id: body.repo_id,
schedule: body.schedule,
description: body.description.as_deref(),
config: body.config,
phases: body
.phases
.into_iter()
.map(|p| NewMissionPhase {
kind: p.kind,
order_idx: p.order_idx,
config: p.config,
})
.collect(),
};
let id = cm_db::repo::missions::insert(&state.pool, new).await?;
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::Internal)?;
Ok(Json(mission))
}
pub async fn get(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<MissionDetail>, ApiError> {
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
let phases = cm_db::repo::missions::phases_for(&state.pool, id).await?;
let tasks = cm_db::repo::missions::tasks_for(&state.pool, id).await?;
let artifacts = cm_db::repo::missions::artifacts_for(&state.pool, id).await?;
Ok(Json(MissionDetail {
mission,
phases,
tasks,
artifacts,
}))
}
pub async fn set_status(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
Json(body): Json<SetStatusRequest>,
) -> Result<Json<Mission>, ApiError> {
let allowed = ["draft", "running", "completed", "failed", "cancelled"];
if !allowed.contains(&body.status.as_str()) {
return Err(ApiError::BadRequest);
}
cm_db::repo::missions::set_status(
&state.pool,
id,
user.workspace_id.as_uuid(),
&body.status,
)
.await?;
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
Ok(Json(mission))
}
+1
View File
@@ -13,6 +13,7 @@ pub mod gateway;
pub mod health;
pub mod identity;
pub mod loops;
pub mod missions;
pub mod nodes;
pub mod oauth;
pub mod orgs;