research: reject-with-revision loop (R2)
Before: reviewer rejected a publish → audit log flipped, topic stayed in reviewing, no way to feed the critique back into the run pipeline. Reviewers with revision notes had to eat them or hand-message the coordinator. Now: reject accepts an optional `notes` field. When present: - Persisted on the research_publish_approvals row (migration 0039). - Topic flips `reviewing → standby` so the next `start_topic` is legal. - `start_topic` reads the most recent rejected-approval notes for the topic and prepends "PRIOR REVIEW NOTES (address these in this revision):\n<notes>\n---" to the coordinator task. Loop closes through the same run pipeline — no new spawn code path, which means the reviewer's guidance flows through the same topology_worker, run_events, outcome-writer chain and lands as a fresh research_outcomes row (versioned, prior drafts preserved). No notes on reject = legacy behavior (topic stays in reviewing, publish requests still allowed). Migration 0039 adds nullable `notes TEXT` to research_publish_approvals. `decide()` gains a `notes: Option<&str>` parameter (only one caller, updated inline). New `latest_rejection_notes(pool, topic_id)` helper for start_topic. Frontend: - rejectPublish(id, notes?) now sends a JSON body when notes are provided. - ResearchCanvas reject button opens an inline form with a textarea + Cancel/"Send back for revision" pair. Empty notes → plain reject. - Button label switches: "Send back for revision" when notes present, "Reject without notes" when empty. Follow-up: - Notes shown in the review UI on the resulting draft so the next reviewer sees what changed. - Multiple rejection rounds — currently only the LATEST rejection's notes surface. Accumulating history is a schema-only tweak.
This commit is contained in:
@@ -102,25 +102,56 @@ pub async fn list_pending(
|
||||
|
||||
/// 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).
|
||||
/// decided (idempotent). Optional `notes` are stashed on the row so a
|
||||
/// subsequent `start_topic` can pick them up as revision guidance
|
||||
/// (R2 — reject-with-revision).
|
||||
pub async fn decide(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
decided_by: Uuid,
|
||||
approve: bool,
|
||||
notes: Option<&str>,
|
||||
) -> Result<bool, DbError> {
|
||||
let new_status = if approve { "approved" } else { "rejected" };
|
||||
let result = sqlx::query!(
|
||||
// Dynamic sqlx::query so the new `notes` column doesn't need a fresh
|
||||
// .sqlx offline cache entry — the value is bound at runtime.
|
||||
let result = sqlx::query(
|
||||
"UPDATE research_publish_approvals
|
||||
SET status = $4, decided_by = $3, decided_at = now()
|
||||
SET status = $4, decided_by = $3, decided_at = now(), notes = $5
|
||||
WHERE id = $1 AND workspace_id = $2 AND status = 'pending'",
|
||||
id,
|
||||
workspace_id,
|
||||
decided_by,
|
||||
new_status,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(workspace_id)
|
||||
.bind(decided_by)
|
||||
.bind(new_status)
|
||||
.bind(notes)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Most-recent rejected-approval notes for a topic, or None. Used by
|
||||
/// `start_topic` to prepend a reviewer's revision guidance to the next
|
||||
/// coordinator task. Only returns non-empty strings; a rejection with
|
||||
/// no notes reads the same as no rejection at all.
|
||||
pub async fn latest_rejection_notes(
|
||||
pool: &PgPool,
|
||||
topic_id: Uuid,
|
||||
) -> Result<Option<String>, DbError> {
|
||||
use sqlx::Row;
|
||||
let row: Option<sqlx::postgres::PgRow> = sqlx::query(
|
||||
"SELECT notes
|
||||
FROM research_publish_approvals
|
||||
WHERE topic_id = $1 AND status = 'rejected' AND notes IS NOT NULL
|
||||
ORDER BY decided_at DESC NULLS LAST
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(topic_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row
|
||||
.and_then(|r| r.try_get::<Option<String>, _>("notes").ok().flatten())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user