research: publishing → published + artifact download (R1)
Before this commit, approve_publish left topics stuck in 'publishing' forever — the sidebar 'published' bucket was always empty and nothing surfaced the artifact. Two-part fix: State machine — approve_publish now transitions reviewing → publishing → published in one API call. Real async packaging isn't a thing yet because the artifact IS the markdown already written to research_outcomes when the last run completed (topology_worker). The intermediate 'publishing' state is preserved (schema-level trigger stamps published_at on landing there) so we keep the option to detour through it later for pdf render / mirror-to-store / etc. Download endpoint — GET /api/research/:id/artifact returns the latest outcome as text/markdown with Content-Disposition: attachment. Filename sanitizes the topic title to ascii-alnum + dash and appends the outcome version so accumulated revision drafts (post R2) don't clobber. Workspace ownership check via research_topics::get; 404 if no outcome yet (reviewers browsing before a run completes). Frontend — ResearchList shows a green Download icon-button next to Delete when status === 'published'. Clicks trigger a plain anchor download of the .md — no JS blob dance needed since the response is already an attachment. Follow-up queued: pdf render (server-side or client-side of the md), mirror-to-store (S3-ish or Obsidian vault) as an async step during the publishing→published detour.
This commit is contained in:
@@ -18,7 +18,8 @@
|
||||
//! POST /api/research/wizard/refine one-shot LLM refine helper
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use cm_llm::{ChatMessage, ChatRequest, ChatRole, ContentPart, LlmEvent};
|
||||
use futures::StreamExt;
|
||||
@@ -880,8 +881,18 @@ async fn decide_publish(
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
if approve {
|
||||
// reviewing → publishing (set_status also stamps published_at when
|
||||
// landing in `publishing` for the first time).
|
||||
// reviewing → publishing → published in one API call.
|
||||
//
|
||||
// Real async packaging isn't a thing yet — the artifact is the
|
||||
// markdown body already stored in research_outcomes when the
|
||||
// final run completed (see topology_worker). Two transitions:
|
||||
//
|
||||
// 1. set_status('publishing') stamps published_at on the first
|
||||
// landing (schema-level trigger — see set_status docs).
|
||||
// 2. set_status('published') is the terminal state that the
|
||||
// sidebar bucket count reads. Nothing else fires; if we later
|
||||
// add real packaging (pdf render, mirror to a store) we can
|
||||
// make step 2 an async job driven off the 'publishing' row.
|
||||
cm_db::repo::research_topics::set_status(
|
||||
&state.pool,
|
||||
approval.topic_id,
|
||||
@@ -889,9 +900,15 @@ async fn decide_publish(
|
||||
"publishing",
|
||||
)
|
||||
.await?;
|
||||
cm_db::repo::research_topics::set_status(
|
||||
&state.pool,
|
||||
approval.topic_id,
|
||||
user.workspace_id.as_uuid(),
|
||||
"published",
|
||||
)
|
||||
.await?;
|
||||
// The research work is done — tear down the per-topic team
|
||||
// container. Artifact-writing (R1) doesn't need a live runtime;
|
||||
// it reads run_events from the durable log.
|
||||
// container. Artifact-writing already happened during runs.
|
||||
crate::research_container::teardown(approval.topic_id).await;
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
@@ -913,6 +930,40 @@ pub async fn reject_publish(
|
||||
decide_publish(state, user, id, false).await
|
||||
}
|
||||
|
||||
/// `GET /api/research/:id/artifact` — download the latest outcome as
|
||||
/// markdown (Content-Disposition: attachment). Any topic that has a
|
||||
/// stored outcome can serve one — we don't gate on status='published'
|
||||
/// because reviewers may want to inspect the draft before approving.
|
||||
/// Workspace-scoped ownership check enforced via research_topics::get.
|
||||
pub async fn get_artifact(
|
||||
State(state): State<AppState>,
|
||||
Authed(user): Authed,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let topic = cm_db::repo::research_topics::get(&state.pool, id, user.workspace_id.as_uuid())
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let outcome = cm_db::repo::research_outcomes::latest(&state.pool, id)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let safe_title: String = topic
|
||||
.title
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
|
||||
.collect();
|
||||
let filename = format!("{}-v{}.md", safe_title.trim_matches('-'), outcome.version);
|
||||
Ok((
|
||||
[
|
||||
(header::CONTENT_TYPE, "text/markdown; charset=utf-8".to_string()),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{filename}\""),
|
||||
),
|
||||
],
|
||||
outcome.body_md,
|
||||
))
|
||||
}
|
||||
|
||||
// ── wizard refine ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
Reference in New Issue
Block a user