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
+4
View File
@@ -438,6 +438,10 @@ pub fn router(state: AppState) -> Router {
"/api/research/wizard/refine", "/api/research/wizard/refine",
post(routes::research::refine_wizard), post(routes::research::refine_wizard),
) )
.route(
"/api/research/{id}/artifact",
get(routes::research::get_artifact),
)
.route( .route(
"/api/loops", "/api/loops",
get(routes::loops::list_loops).post(routes::loops::create_loop), get(routes::loops::list_loops).post(routes::loops::create_loop),
+56 -5
View File
@@ -18,7 +18,8 @@
//! POST /api/research/wizard/refine one-shot LLM refine helper //! 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::{header, StatusCode};
use axum::response::IntoResponse;
use axum::Json; use axum::Json;
use cm_llm::{ChatMessage, ChatRequest, ChatRole, ContentPart, LlmEvent}; use cm_llm::{ChatMessage, ChatRequest, ChatRole, ContentPart, LlmEvent};
use futures::StreamExt; use futures::StreamExt;
@@ -880,8 +881,18 @@ async fn decide_publish(
return Ok(StatusCode::NO_CONTENT); return Ok(StatusCode::NO_CONTENT);
} }
if approve { if approve {
// reviewing → publishing (set_status also stamps published_at when // reviewing → publishing → published in one API call.
// landing in `publishing` for the first time). //
// 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( cm_db::repo::research_topics::set_status(
&state.pool, &state.pool,
approval.topic_id, approval.topic_id,
@@ -889,9 +900,15 @@ async fn decide_publish(
"publishing", "publishing",
) )
.await?; .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 // The research work is done — tear down the per-topic team
// container. Artifact-writing (R1) doesn't need a live runtime; // container. Artifact-writing already happened during runs.
// it reads run_events from the durable log.
crate::research_container::teardown(approval.topic_id).await; crate::research_container::teardown(approval.topic_id).await;
} }
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
@@ -913,6 +930,40 @@ pub async fn reject_publish(
decide_publish(state, user, id, false).await 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 ────────────────────────────────────────────────────────── // ── wizard refine ──────────────────────────────────────────────────────────
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -6,7 +6,7 @@
// outcomes; topology_runs.research_topic_id is SET NULL). // outcomes; topology_runs.research_topic_id is SET NULL).
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Plus, Trash2 } from "lucide-react"; import { Download, Plus, Trash2 } from "lucide-react";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { import {
@@ -316,6 +316,28 @@ export function ResearchList({
marginTop: 2, marginTop: 2,
}} }}
> >
{t.status === "published" ? (
<a
href={`/api/research/${t.id}/artifact`}
title="Download artifact (.md)"
aria-label="Download artifact"
style={{
width: 24,
height: 24,
borderRadius: 6,
border: "1px solid rgba(255,255,255,.08)",
background: "transparent",
color: "#6fd0c0",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
textDecoration: "none",
}}
>
<Download aria-hidden size={12} />
</a>
) : null}
<button <button
type="button" type="button"
title="Delete topic" title="Delete topic"