slice 1: missions data model + migration
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:
co-authored by
Claude Opus 4.7
parent
68d84bf1ad
commit
fbefc67878
@@ -0,0 +1,165 @@
|
||||
//! `/api/missions/*` — the unified workflow surface (Slice 1).
|
||||
//!
|
||||
//! This is a skeleton: create/list/get/status only. Slices 4–8 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))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user