research: publish approval gate + explicit state transitions
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:
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user