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:
@@ -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),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user