research: publish approval gate + explicit state transitions
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 24s
ci / rust (push) Successful in 3m44s
ci / publish (push) Successful in 2m14s
ci / e2e (push) Failing after 29m59s

Fourth commit of the Research + Loops arc. Completes the state machine
for research topics with the publish approval gate the spec asked for.

Migration 0032 — research_publish_approvals
  Dedicated small table (id, workspace_id, topic_id, requested_by,
  status, decided_by/at, created_at). Keeping it separate from the
  existing `approvals` table (0001) because that one is tightly coupled
  to gated tool calls inside an agent run — session_key + run_id +
  action_type + category + payload + preview + requested_by_agent, all
  NOT NULL. Forcing those nullable would ripple through cm_safety;
  cleaner to give publish approvals their own two-transition state
  machine.

New endpoints
  POST /api/research/:id/submit-review               processing → reviewing
                                                     (v1 caller-driven; the
                                                     orchestrator hook comes
                                                     when we wire actual runs)
  POST /api/research/:id/request-publish             creates a pending
                                                     approval. Rejects with
                                                     409 if the topic already
                                                     has one open.
  GET  /api/research/publish-approvals               list workspace's pending
  POST /api/research/publish-approvals/:id/approve   flips approval to
                                                     approved + transitions
                                                     the topic
                                                     reviewing → publishing
                                                     (which stamps
                                                     published_at)
  POST /api/research/publish-approvals/:id/reject    stays in reviewing; new
                                                     requests allowed

The approve/reject write is an atomic UPDATE ... WHERE status = 'pending';
the decide() repo function returns whether the caller won the race so
concurrent double-approves collapse to a single topic transition.

State machine after this commit:
  standby ─POST /start─▶ processing ─POST /submit-review─▶ reviewing
    ─POST /request-publish + approve─▶ publishing ─(future: artifact
    assembly)─▶ published
This commit is contained in:
Omar Sobh
2026-07-06 06:21:29 -07:00
parent 4b48c521eb
commit 973eeb272e
10 changed files with 570 additions and 12 deletions
+155 -12
View File
@@ -1,16 +1,21 @@
//! Research topic endpoints — the container CRUD + the wizard's one-shot LLM
//! refine call. The state machine's advancing transitions (processing →
//! reviewing, reviewing → publishing) land in later commits alongside the
//! orchestrator + approval-gate wiring. What ships here:
//! Research topic endpoints — CRUD, state-machine transitions, the publish
//! approval gate, and the wizard's one-shot LLM refine call. Reads for the
//! publish gate share this module because they're semantically the topic's
//! terminal action.
//!
//! GET /api/research list workspace's topics
//! POST /api/research create (accepts wizard output)
//! GET /api/research/:id detail (topic + attached agents)
//! PATCH /api/research/:id update non-status fields
//! POST /api/research/:id/agents attach agent (idempotent)
//! DELETE /api/research/:id/agents/:agent detach
//! POST /api/research/:id/start standby → processing
//! POST /api/research/wizard/refine one-shot LLM refine helper
//! GET /api/research list workspace's topics
//! POST /api/research create (accepts wizard output)
//! GET /api/research/:id detail (topic + agents)
//! PATCH /api/research/:id update non-status fields
//! POST /api/research/:id/agents attach agent (idempotent)
//! DELETE /api/research/:id/agents/:agent detach
//! POST /api/research/:id/start standby → processing
//! POST /api/research/:id/submit-review processing → reviewing
//! POST /api/research/:id/request-publish create pending publish approval
//! GET /api/research/publish-approvals list workspace's pending approvals
//! POST /api/research/publish-approvals/:id/approve reviewing → publishing
//! POST /api/research/publish-approvals/:id/reject stays in reviewing
//! POST /api/research/wizard/refine one-shot LLM refine helper
use axum::extract::{Path, State};
use axum::http::StatusCode;
@@ -238,6 +243,144 @@ pub async fn start_topic(
Ok(StatusCode::NO_CONTENT)
}
/// `POST /api/research/:id/submit-review` — flips status `processing → reviewing`.
/// v1 is caller-driven: the UI hits this when the human is happy with the
/// runs' output. A later commit hooks this from the orchestrator on the
/// last topology_run's completion.
pub async fn submit_review(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
let topic = cm_db::repo::research_topics::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
if topic.status != "processing" {
return Err(ApiError::Conflict);
}
cm_db::repo::research_topics::set_status(
&state.pool,
id,
user.workspace_id.as_uuid(),
"reviewing",
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
// ── publish approval gate ─────────────────────────────────────────────────
#[derive(serde::Serialize)]
pub struct PublishApprovalCreated {
pub approval_id: Uuid,
}
/// `POST /api/research/:id/request-publish` — a workspace member requests a
/// publish. Topic must be in `reviewing`. Rejects with 409 if there's
/// already a pending request (one at a time). The topic stays in `reviewing`
/// until an approver decides.
pub async fn request_publish(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<(StatusCode, Json<PublishApprovalCreated>), ApiError> {
let topic = cm_db::repo::research_topics::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
if topic.status != "reviewing" {
return Err(ApiError::Conflict);
}
if cm_db::repo::research_publish_approvals::pending_for_topic(&state.pool, id)
.await?
.is_some()
{
return Err(ApiError::Conflict);
}
let approval_id = cm_db::repo::research_publish_approvals::create(
&state.pool,
user.workspace_id.as_uuid(),
id,
user.user_id.as_uuid(),
)
.await?;
Ok((
StatusCode::CREATED,
Json(PublishApprovalCreated { approval_id }),
))
}
pub async fn list_pending_publish(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Vec<cm_db::repo::research_publish_approvals::PublishApproval>>, ApiError> {
Ok(Json(
cm_db::repo::research_publish_approvals::list_pending(
&state.pool,
user.workspace_id.as_uuid(),
)
.await?,
))
}
/// Shared body of approve + reject. On approve, transition the topic
/// `reviewing → publishing` (and set published_at via set_status). On
/// reject, topic stays put; new requests are allowed.
async fn decide_publish(
state: AppState,
user: cm_auth::AuthedUser,
id: Uuid,
approve: bool,
) -> Result<StatusCode, ApiError> {
let approval =
cm_db::repo::research_publish_approvals::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
if approval.status != "pending" {
return Err(ApiError::Conflict);
}
let landed = cm_db::repo::research_publish_approvals::decide(
&state.pool,
id,
user.workspace_id.as_uuid(),
user.user_id.as_uuid(),
approve,
)
.await?;
// Someone else won the race — treat as a no-op success; the topic
// transition already happened (or didn't) with their decision.
if !landed {
return Ok(StatusCode::NO_CONTENT);
}
if approve {
// reviewing → publishing (set_status also stamps published_at when
// landing in `publishing` for the first time).
cm_db::repo::research_topics::set_status(
&state.pool,
approval.topic_id,
user.workspace_id.as_uuid(),
"publishing",
)
.await?;
}
Ok(StatusCode::NO_CONTENT)
}
pub async fn approve_publish(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
decide_publish(state, user, id, true).await
}
pub async fn reject_publish(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
decide_publish(state, user, id, false).await
}
// ── wizard refine ──────────────────────────────────────────────────────────
#[derive(Deserialize)]