research: don't advance to reviewing without an outcome + guard-before-write
Two bugs that combine to produce the 409 you get when clicking Approve:
1) notify_run_completed (topology_worker post-hook) was advancing
topic status processing → reviewing whenever the last sibling run
terminated — success OR fail. A failed run with 0 outcomes still
pushed the topic to `reviewing`, the canvas rendered the "Request
publish" affordance, and the reviewer clicked Approve on nothing.
Fixed by adding an EXISTS(research_outcomes …) clause to the
UPDATE. Topic stays in `processing` when no outcome exists; the
loop's next iteration still has a chance to produce one.
2) decide_publish was calling
research_publish_approvals::decide(approve=true)
FIRST (which flips the row to `status='approved'`) and then
running the "no outcome? 409" guard SECOND. On the 409 return,
the DB was left half-flipped: approval says approved, topic still
in reviewing, no outcome exists, and every future click to the
same approval returns 409 on the "already decided" guard —
leaving reviewers with no way forward.
Fixed by moving the outcome-existence check BEFORE the decide()
call. On 409 now nothing was written, so the reviewer can try
again cleanly once an outcome is produced.
Also unstuck the current stuck row out-of-band (SQL UPDATE to reset
the approval to pending + topic to processing) so the user isn't
forced to delete the topic to escape the 409 loop.
sqlx dynamic query — the new EXISTS clause wasn't in the offline
cache so I switched notify_run_completed to plain `sqlx::query`.
This commit is contained in:
@@ -972,6 +972,19 @@ async fn decide_publish(
|
||||
if approval.status != "pending" {
|
||||
return Err(ApiError::Conflict);
|
||||
}
|
||||
// Pre-flight the no-outcome guard BEFORE we flip the approval row.
|
||||
// Previous ordering wrote `status = 'approved'` first, then 409'd on
|
||||
// this check — leaving the DB in a half-flipped state (approval
|
||||
// approved, topic still in reviewing, no outcome existed) which
|
||||
// surfaces to reviewers as a permanent "already decided" 409 the
|
||||
// next click.
|
||||
if approve {
|
||||
let outcome =
|
||||
cm_db::repo::research_outcomes::latest(&state.pool, approval.topic_id).await?;
|
||||
if outcome.is_none() {
|
||||
return Err(ApiError::Conflict);
|
||||
}
|
||||
}
|
||||
let notes_ref = notes.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
let landed = cm_db::repo::research_publish_approvals::decide(
|
||||
&state.pool,
|
||||
@@ -988,20 +1001,6 @@ async fn decide_publish(
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
if approve {
|
||||
// Guard: you can't approve-to-publish a topic that has no
|
||||
// outcome. Discovered on first prod run — the pipeline can
|
||||
// silently reach `published` state with zero runs surfacing an
|
||||
// outcome (LLM auth failure, network, etc.), leaving the
|
||||
// download endpoint at a 404 with no user-facing warning.
|
||||
//
|
||||
// Refuse with 409 so the frontend can render "no artifact — run
|
||||
// failed, check pipeline diagnostics" and the reviewer isn't
|
||||
// fooled into thinking approval is a no-op.
|
||||
let outcome =
|
||||
cm_db::repo::research_outcomes::latest(&state.pool, approval.topic_id).await?;
|
||||
if outcome.is_none() {
|
||||
return Err(ApiError::Conflict);
|
||||
}
|
||||
// reviewing → publishing → published in one API call.
|
||||
//
|
||||
// Real async packaging isn't a thing yet — the artifact is the
|
||||
|
||||
Reference in New Issue
Block a user