research: publishing → published + artifact download (R1)
ci / gates (push) Successful in 6s
ci / rust (push) Failing after 10s
ci / frontend (push) Successful in 26s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

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:
Omar Sobh
2026-07-09 14:05:21 -07:00
parent f60df36717
commit f9e8d8d779
3 changed files with 83 additions and 6 deletions
+56 -5
View File
@@ -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)]