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:
+17
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "INSERT INTO research_publish_approvals\n (id, workspace_id, topic_id, requested_by, status)\n VALUES ($1, $2, $3, $4, 'pending')",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "208b41dc1f8cb9f6ff46ce6a01d15c2ddbf604d6a4062f95b1eb99720c244ff2"
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE research_publish_approvals\n SET status = $4, decided_by = $3, decided_at = now()\n WHERE id = $1 AND workspace_id = $2 AND status = 'pending'",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "40d90c412b44009003ebc692b143382bd157663ed5f2481f08d7abfcc5129b27"
|
||||||
|
}
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, workspace_id, topic_id, requested_by, status,\n decided_by, decided_at, created_at\n FROM research_publish_approvals\n WHERE topic_id = $1 AND status = 'pending'\n LIMIT 1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "topic_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "requested_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "decided_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "decided_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "989cc49438587f6d49c0e78b716b5001637c064c9128bd9cef639536c4450aeb"
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, workspace_id, topic_id, requested_by, status,\n decided_by, decided_at, created_at\n FROM research_publish_approvals\n WHERE id = $1 AND workspace_id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "topic_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "requested_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "decided_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "decided_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "e600913908298405fd10dbd7f70d9fd72406f06d5fe165c5e8abfbd5e0c29b19"
|
||||||
|
}
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, workspace_id, topic_id, requested_by, status,\n decided_by, decided_at, created_at\n FROM research_publish_approvals\n WHERE workspace_id = $1 AND status = 'pending'\n ORDER BY created_at DESC",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "topic_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "requested_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "decided_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "decided_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "ee97028bde490c0b2ac1fc5746d8ecd3079a5f61875925b1153da6bd6b84bfb1"
|
||||||
|
}
|
||||||
@@ -399,6 +399,26 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/research/{id}/start",
|
"/api/research/{id}/start",
|
||||||
post(routes::research::start_topic),
|
post(routes::research::start_topic),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/research/{id}/submit-review",
|
||||||
|
post(routes::research::submit_review),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/research/{id}/request-publish",
|
||||||
|
post(routes::research::request_publish),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/research/publish-approvals",
|
||||||
|
get(routes::research::list_pending_publish),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/research/publish-approvals/{id}/approve",
|
||||||
|
post(routes::research::approve_publish),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/research/publish-approvals/{id}/reject",
|
||||||
|
post(routes::research::reject_publish),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/research/wizard/refine",
|
"/api/research/wizard/refine",
|
||||||
post(routes::research::refine_wizard),
|
post(routes::research::refine_wizard),
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
//! Research topic endpoints — the container CRUD + the wizard's one-shot LLM
|
//! Research topic endpoints — CRUD, state-machine transitions, the publish
|
||||||
//! refine call. The state machine's advancing transitions (processing →
|
//! approval gate, and the wizard's one-shot LLM refine call. Reads for the
|
||||||
//! reviewing, reviewing → publishing) land in later commits alongside the
|
//! publish gate share this module because they're semantically the topic's
|
||||||
//! orchestrator + approval-gate wiring. What ships here:
|
//! terminal action.
|
||||||
//!
|
//!
|
||||||
//! GET /api/research list workspace's topics
|
//! GET /api/research list workspace's topics
|
||||||
//! POST /api/research create (accepts wizard output)
|
//! POST /api/research create (accepts wizard output)
|
||||||
//! GET /api/research/:id detail (topic + attached agents)
|
//! GET /api/research/:id detail (topic + agents)
|
||||||
//! PATCH /api/research/:id update non-status fields
|
//! PATCH /api/research/:id update non-status fields
|
||||||
//! POST /api/research/:id/agents attach agent (idempotent)
|
//! POST /api/research/:id/agents attach agent (idempotent)
|
||||||
//! DELETE /api/research/:id/agents/:agent detach
|
//! DELETE /api/research/:id/agents/:agent detach
|
||||||
//! POST /api/research/:id/start standby → processing
|
//! POST /api/research/:id/start standby → processing
|
||||||
//! POST /api/research/wizard/refine one-shot LLM refine helper
|
//! 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::extract::{Path, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
@@ -238,6 +243,144 @@ pub async fn start_topic(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
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 ──────────────────────────────────────────────────────────
|
// ── wizard refine ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub mod node_tools;
|
|||||||
pub mod nodes;
|
pub mod nodes;
|
||||||
pub mod orgs;
|
pub mod orgs;
|
||||||
pub mod outbox;
|
pub mod outbox;
|
||||||
|
pub mod research_publish_approvals;
|
||||||
pub mod research_topics;
|
pub mod research_topics;
|
||||||
pub mod routine_runs;
|
pub mod routine_runs;
|
||||||
pub mod routines;
|
pub mod routines;
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
//! Publish approval gate for research topics — see 0032 migration header.
|
||||||
|
//! Small table with a small state machine (pending → approved | rejected).
|
||||||
|
//! One pending row per topic at a time; enforced at the route layer by
|
||||||
|
//! looking up `pending_for_topic` before create.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::DbError;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PublishApproval {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub workspace_id: Uuid,
|
||||||
|
pub topic_id: Uuid,
|
||||||
|
pub requested_by: Uuid,
|
||||||
|
pub status: String,
|
||||||
|
pub decided_by: Option<Uuid>,
|
||||||
|
pub decided_at: Option<OffsetDateTime>,
|
||||||
|
pub created_at: OffsetDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
pool: &PgPool,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
topic_id: Uuid,
|
||||||
|
requested_by: Uuid,
|
||||||
|
) -> Result<Uuid, DbError> {
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO research_publish_approvals
|
||||||
|
(id, workspace_id, topic_id, requested_by, status)
|
||||||
|
VALUES ($1, $2, $3, $4, 'pending')",
|
||||||
|
id,
|
||||||
|
workspace_id,
|
||||||
|
topic_id,
|
||||||
|
requested_by,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pending approval for a topic, if any. The route layer uses this to
|
||||||
|
/// short-circuit before writing a duplicate request.
|
||||||
|
pub async fn pending_for_topic(
|
||||||
|
pool: &PgPool,
|
||||||
|
topic_id: Uuid,
|
||||||
|
) -> Result<Option<PublishApproval>, DbError> {
|
||||||
|
let row = sqlx::query_as!(
|
||||||
|
PublishApproval,
|
||||||
|
"SELECT id, workspace_id, topic_id, requested_by, status,
|
||||||
|
decided_by, decided_at, created_at
|
||||||
|
FROM research_publish_approvals
|
||||||
|
WHERE topic_id = $1 AND status = 'pending'
|
||||||
|
LIMIT 1",
|
||||||
|
topic_id,
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
) -> Result<Option<PublishApproval>, DbError> {
|
||||||
|
let row = sqlx::query_as!(
|
||||||
|
PublishApproval,
|
||||||
|
"SELECT id, workspace_id, topic_id, requested_by, status,
|
||||||
|
decided_by, decided_at, created_at
|
||||||
|
FROM research_publish_approvals
|
||||||
|
WHERE id = $1 AND workspace_id = $2",
|
||||||
|
id,
|
||||||
|
workspace_id,
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_pending(
|
||||||
|
pool: &PgPool,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
) -> Result<Vec<PublishApproval>, DbError> {
|
||||||
|
let rows = sqlx::query_as!(
|
||||||
|
PublishApproval,
|
||||||
|
"SELECT id, workspace_id, topic_id, requested_by, status,
|
||||||
|
decided_by, decided_at, created_at
|
||||||
|
FROM research_publish_approvals
|
||||||
|
WHERE workspace_id = $1 AND status = 'pending'
|
||||||
|
ORDER BY created_at DESC",
|
||||||
|
workspace_id,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically flip a pending row to approved/rejected. Returns whether the
|
||||||
|
/// caller was the one who won the race — false when the row was already
|
||||||
|
/// decided (idempotent).
|
||||||
|
pub async fn decide(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
decided_by: Uuid,
|
||||||
|
approve: bool,
|
||||||
|
) -> Result<bool, DbError> {
|
||||||
|
let new_status = if approve { "approved" } else { "rejected" };
|
||||||
|
let result = sqlx::query!(
|
||||||
|
"UPDATE research_publish_approvals
|
||||||
|
SET status = $4, decided_by = $3, decided_at = now()
|
||||||
|
WHERE id = $1 AND workspace_id = $2 AND status = 'pending'",
|
||||||
|
id,
|
||||||
|
workspace_id,
|
||||||
|
decided_by,
|
||||||
|
new_status,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected() > 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- Publish approval gate for research topics. Distinct from the existing
|
||||||
|
-- `approvals` table (0001), which 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). None of those apply
|
||||||
|
-- to a publish-of-a-static-topic gate; forcing them nullable would ripple
|
||||||
|
-- through cm_safety, so we give publish approvals their own small table.
|
||||||
|
--
|
||||||
|
-- Lifecycle:
|
||||||
|
-- 1. Topic reaches `reviewing` (either the orchestrator flipped it after
|
||||||
|
-- the last agent run completed, or the user hit /submit-review).
|
||||||
|
-- 2. A workspace member calls /api/research/:id/request-publish, which
|
||||||
|
-- writes a row here with status='pending'.
|
||||||
|
-- 3. Another workspace member (any user in the workspace; per-role gating
|
||||||
|
-- can layer on later) approves or rejects.
|
||||||
|
-- 4. On the first approval, the topic transitions `reviewing → publishing`
|
||||||
|
-- and (after any artifact assembly) `publishing → published`.
|
||||||
|
-- 5. On a reject, the topic stays in `reviewing` and a new request is
|
||||||
|
-- allowed. Old rejected rows are audit history.
|
||||||
|
|
||||||
|
CREATE TABLE research_publish_approvals (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
|
||||||
|
topic_id UUID NOT NULL REFERENCES research_topics (id) ON DELETE CASCADE,
|
||||||
|
requested_by UUID NOT NULL REFERENCES users (id) ON DELETE RESTRICT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||||
|
decided_by UUID REFERENCES users (id) ON DELETE RESTRICT,
|
||||||
|
decided_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Sidebar "pending approvals" list query.
|
||||||
|
CREATE INDEX research_publish_approvals_pending_idx
|
||||||
|
ON research_publish_approvals (workspace_id, created_at DESC)
|
||||||
|
WHERE status = 'pending';
|
||||||
|
|
||||||
|
-- Look up "does this topic already have a pending approval?" before letting
|
||||||
|
-- a caller create a second one.
|
||||||
|
CREATE INDEX research_publish_approvals_topic_pending_idx
|
||||||
|
ON research_publish_approvals (topic_id)
|
||||||
|
WHERE status = 'pending';
|
||||||
Reference in New Issue
Block a user