diff --git a/crates/archivr-core/src/archive.rs b/crates/archivr-core/src/archive.rs index 2b7722a..ea63c46 100644 --- a/crates/archivr-core/src/archive.rs +++ b/crates/archivr-core/src/archive.rs @@ -51,9 +51,12 @@ pub struct EntryDetail { pub source_metadata_json: String, pub display_metadata_json: Option, pub artifacts: Vec, - /// Most recently updated summary for this entry, if any has ever been - /// requested. Always `None` on a fresh capture — summarization is manual. + /// Most recent completed summary for this entry. Always `None` on a fresh + /// capture — summarization is manual. pub latest_summary: Option, + /// Latest non-completed generation attempt, kept separate so a replacement + /// never displaces readable completed content. + pub summary_attempt: Option, } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] @@ -354,7 +357,8 @@ pub fn get_entry_detail( })? .collect::>>()?; - let latest_summary = database::latest_entry_summary(conn, entry_id)?; + let latest_summary = database::latest_completed_entry_summary(conn, entry_id)?; + let summary_attempt = database::latest_entry_summary_attempt(conn, entry_id)?; Ok(Some(EntryDetail { summary, @@ -363,6 +367,7 @@ pub fn get_entry_detail( display_metadata_json, artifacts, latest_summary, + summary_attempt, })) } diff --git a/crates/archivr-core/src/database.rs b/crates/archivr-core/src/database.rs index 5b7a743..31318e0 100644 --- a/crates/archivr-core/src/database.rs +++ b/crates/archivr-core/src/database.rs @@ -1644,7 +1644,8 @@ pub fn find_entry_summary( } /// Most recently touched summary for an entry, whatever its status. -/// Backs `EntryDetail.latest_summary` and the GET summary route. +/// Used where a caller explicitly needs the most recent attempt regardless of +/// whether it has completed. pub fn latest_entry_summary( conn: &Connection, entry_id: i64, @@ -1679,6 +1680,37 @@ pub fn latest_completed_entry_summary( .map_err(Into::into) } +/// The newest non-completed replacement attempt after the retained completed +/// result. This includes failed rows so authenticated callers can show a recent +/// failure beside readable content, but suppresses historical failures after a +/// newer successful regeneration. +pub fn latest_entry_summary_attempt( + conn: &Connection, + entry_id: i64, +) -> Result> { + conn.query_row( + &format!( + "{ENTRY_SUMMARY_COLS} WHERE s.entry_id = ?1 AND s.status != 'completed' + AND ( + NOT EXISTS ( + SELECT 1 FROM entry_summaries c + WHERE c.entry_id = s.entry_id AND c.status = 'completed' + ) + OR (s.updated_at, s.id) > ( + SELECT c.updated_at, c.id FROM entry_summaries c + WHERE c.entry_id = s.entry_id AND c.status = 'completed' + ORDER BY c.completed_at DESC, c.updated_at DESC, c.id DESC LIMIT 1 + ) + ) + ORDER BY s.updated_at DESC, s.id DESC LIMIT 1" + ), + [entry_id], + map_entry_summary, + ) + .optional() + .map_err(Into::into) +} + pub fn create_archive_run( conn: &Connection, created_by_user_id: i64, @@ -4719,6 +4751,58 @@ mod tests { ); } + #[test] + fn latest_summary_attempt_is_separate_from_the_retained_completed_summary() { + let c = conn(); + let entry = create_entry_fixture(&c, "private", None, None); + let completed = + upsert_pending_entry_summary(&c, entry.id, "claude_cli", None, "v1", "old").unwrap(); + update_entry_summary_status(&c, &completed, "completed", Some("previous"), None).unwrap(); + let pending = + upsert_pending_entry_summary(&c, entry.id, "claude_cli", None, "v1", "new").unwrap(); + + assert_eq!( + latest_completed_entry_summary(&c, entry.id).unwrap().unwrap().summary_uid, + completed + ); + assert_eq!( + latest_entry_summary_attempt(&c, entry.id).unwrap().unwrap().summary_uid, + pending + ); + + update_entry_summary_status(&c, &pending, "failed", None, Some("boom")).unwrap(); + assert_eq!( + latest_completed_entry_summary(&c, entry.id).unwrap().unwrap().summary_text.as_deref(), + Some("previous") + ); + assert_eq!( + latest_entry_summary_attempt(&c, entry.id).unwrap().unwrap().status, + "failed" + ); + + let successful_replacement = + upsert_pending_entry_summary(&c, entry.id, "claude_cli", None, "v1", "newer").unwrap(); + update_entry_summary_status( + &c, + &successful_replacement, + "completed", + Some("replacement"), + None, + ) + .unwrap(); + assert_eq!( + latest_completed_entry_summary(&c, entry.id) + .unwrap() + .unwrap() + .summary_uid, + successful_replacement + ); + assert!( + latest_entry_summary_attempt(&c, entry.id).unwrap().is_none(), + "a failed attempt predating a successful replacement must not remain visible" + ); + } + #[test] fn fail_stalled_entry_summaries_marks_pending_and_running_with_restart_message() { let c = conn(); diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index 971b809..71049fc 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -519,6 +519,7 @@ async fn entry_detail( let entry_id = database::entry_id_for_uid(&conn, &entry_uid)? .ok_or(ApiError::not_found("entry not found"))?; detail.latest_summary = database::latest_completed_entry_summary(&conn, entry_id)?; + detail.summary_attempt = None; } Ok(Json(detail)) } @@ -552,14 +553,19 @@ async fn entry_summary_handler( } let entry_id = database::entry_id_for_uid(&conn, &entry_uid)? .ok_or(ApiError::not_found("entry not found"))?; - let summary = if matches!(auth_user, AuthUser::Guest) { - database::latest_completed_entry_summary(&conn, entry_id)? + let (summary, attempt) = if matches!(auth_user, AuthUser::Guest) { + (database::latest_completed_entry_summary(&conn, entry_id)?, None) } else { - database::latest_entry_summary(&conn, entry_id)? + ( + database::latest_completed_entry_summary(&conn, entry_id)?, + database::latest_entry_summary_attempt(&conn, entry_id)?, + ) }; - Ok(Json( - serde_json::json!({ "entry_uid": entry_uid, "summary": summary }), - )) + let mut body = serde_json::json!({ "entry_uid": entry_uid, "summary": summary }); + if !matches!(auth_user, AuthUser::Guest) { + body["attempt"] = serde_json::to_value(attempt)?; + } + Ok(Json(body)) } /// `POST /api/archives/:id/entries/:uid/summary` @@ -3209,6 +3215,14 @@ mod tests { let entry = make_test_entry(&archive_path); let session = make_test_session(&auth_path); let conn = database::open_or_initialize(&archive_path).unwrap(); + let completed_uid = database::upsert_pending_entry_summary( + &conn, entry.id, "codex_cli", None, summarizer::PROMPT_VERSION, "completed-public-test", + ) + .unwrap(); + database::update_entry_summary_status( + &conn, &completed_uid, "completed", Some("previous completed summary"), None, + ) + .unwrap(); let summary_uid = database::upsert_pending_entry_summary( &conn, entry.id, "codex_cli", None, summarizer::PROMPT_VERSION, "failed-public-test", ) @@ -3233,7 +3247,9 @@ mod tests { .body(Body::empty()).unwrap()) .await.unwrap(); assert_eq!(public_summary.status(), StatusCode::OK); - assert!(body_json(public_summary).await["summary"].is_null()); + let public_summary = body_json(public_summary).await; + assert_eq!(public_summary["summary"]["summary_uid"], completed_uid); + assert!(public_summary.get("attempt").is_none()); let public_detail = app(registry.clone(), auth_path.clone()) .oneshot(Request::builder() @@ -3241,7 +3257,9 @@ mod tests { .body(Body::empty()).unwrap()) .await.unwrap(); assert_eq!(public_detail.status(), StatusCode::OK); - assert!(body_json(public_detail).await["latest_summary"].is_null()); + let public_detail = body_json(public_detail).await; + assert_eq!(public_detail["latest_summary"]["summary_uid"], completed_uid); + assert!(public_detail["summary_attempt"].is_null()); let authenticated_summary = app(registry.clone(), auth_path.clone()) .oneshot(Request::builder() @@ -3250,8 +3268,10 @@ mod tests { .body(Body::empty()).unwrap()) .await.unwrap(); let authenticated_summary = body_json(authenticated_summary).await; - assert_eq!(authenticated_summary["summary"]["status"], "failed"); - assert_eq!(authenticated_summary["summary"]["error_text"], "provider secret: raw diagnostic"); + assert_eq!(authenticated_summary["summary"]["summary_uid"], completed_uid); + assert_eq!(authenticated_summary["summary"]["summary_text"], "previous completed summary"); + assert_eq!(authenticated_summary["attempt"]["status"], "failed"); + assert_eq!(authenticated_summary["attempt"]["error_text"], "provider secret: raw diagnostic"); let authenticated_detail = app(registry, auth_path) .oneshot(Request::builder() @@ -3260,8 +3280,10 @@ mod tests { .body(Body::empty()).unwrap()) .await.unwrap(); let authenticated_detail = body_json(authenticated_detail).await; - assert_eq!(authenticated_detail["latest_summary"]["status"], "failed"); - assert_eq!(authenticated_detail["latest_summary"]["error_text"], "provider secret: raw diagnostic"); + assert_eq!(authenticated_detail["latest_summary"]["summary_uid"], completed_uid); + assert_eq!(authenticated_detail["latest_summary"]["summary_text"], "previous completed summary"); + assert_eq!(authenticated_detail["summary_attempt"]["status"], "failed"); + assert_eq!(authenticated_detail["summary_attempt"]["error_text"], "provider secret: raw diagnostic"); } #[test] diff --git a/frontend/src/components/ContextRail.jsx b/frontend/src/components/ContextRail.jsx index b750ec8..ae7d635 100644 --- a/frontend/src/components/ContextRail.jsx +++ b/frontend/src/components/ContextRail.jsx @@ -61,10 +61,10 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se useEffect(() => { setFontsOpen(false) }, [detail?.summary?.entry_uid]) // ── Summary state ─────────────────────────────────────────────────────── - // `summary` mirrors the server row. It is seeded from detail.latest_summary so - // the section renders immediately on selection, then kept fresh by polling - // only while a job is non-terminal. + // A completed summary and its replacement attempt are intentionally separate: + // regeneration must not blank or overwrite readable content while it runs. const [summary, setSummary] = useState(null) + const [summaryAttempt, setSummaryAttempt] = useState(null) const [summaryError, setSummaryError] = useState('') const [summaryBusy, setSummaryBusy] = useState(false) const [summaryProvider, setSummaryProvider] = useState(() => { @@ -145,19 +145,20 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se summaryGenerateAbortRef.current = null const detailMatchesSelection = detail?.summary?.entry_uid === selectedEntry?.entry_uid setSummary(detailMatchesSelection ? detail.latest_summary ?? null : null) + setSummaryAttempt(detailMatchesSelection ? detail.summary_attempt ?? null : null) setSummaryError('') setSummaryBusy(false) setIncludeSummaryImages(false) }, [archiveId, selectedEntry?.entry_uid, detail?.summary?.entry_uid]) - // Poll only while the latest summary is non-terminal. Anchoring the effect on - // the status (rather than starting a timer inside the click handler) means a - // job still running when the user navigates away and back is picked up again. - const summaryStatus = summary?.status + // Poll only while a replacement attempt is non-terminal. Anchoring the effect + // on its status means a job still running when the user navigates away and + // back is picked up again without displacing completed content. + const summaryAttemptStatus = summaryAttempt?.status useEffect(() => { clearInterval(summaryPollRef.current) summaryPollRef.current = null - if (summaryStatus !== 'pending' && summaryStatus !== 'running') return + if (summaryAttemptStatus !== 'pending' && summaryAttemptStatus !== 'running') return if (!archiveId || !detail?.summary?.entry_uid) return const entryUid = detail.summary.entry_uid const selectionKey = `${archiveId}:${entryUid}` @@ -169,7 +170,8 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se const res = await fetchEntrySummary(archiveId, entryUid, { signal: controller.signal }) if (controller.signal.aborted || summarySelectionRef.current !== selectionKey) return setSummary(res.summary ?? null) - const st = res.summary?.status + setSummaryAttempt(res.attempt ?? null) + const st = res.attempt?.status if (st !== 'pending' && st !== 'running') { clearInterval(intervalId) if (summaryPollRef.current === intervalId) summaryPollRef.current = null @@ -190,7 +192,7 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se controller.abort() if (summaryPollAbortRef.current === controller) summaryPollAbortRef.current = null } - }, [summaryStatus, archiveId, selectedEntry?.entry_uid, detail?.summary?.entry_uid, summarySelectionKey]) + }, [summaryAttemptStatus, archiveId, selectedEntry?.entry_uid, detail?.summary?.entry_uid, summarySelectionKey]) useEffect(() => () => { clearInterval(summaryPollRef.current) @@ -219,12 +221,13 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se if (res.status === 'completed') { // 200 cache hit: the response *is* the row, no polling needed. setSummary(res) + setSummaryAttempt(null) setSummaryBusy(false) if (summarySelectionRef.current === selectionKey) onDetailRefresh?.() } else { // 202: seed a local pending row so the poll effect starts immediately // rather than waiting a tick for the first GET. - setSummary({ ...(res ?? {}), status: 'pending' }) + setSummaryAttempt({ ...(res ?? {}), status: 'pending' }) } } catch (e) { if (controller.signal.aborted || summarySelectionRef.current !== selectionKey) return @@ -607,9 +610,9 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se const parsed = summary?.status === 'completed' ? parseSummaryText(summary.summary_text) : null - const running = summary?.status === 'pending' || summary?.status === 'running' + const running = summaryAttempt?.status === 'pending' || summaryAttempt?.status === 'running' const unsupportedContent = - (summary?.status === 'failed' && summary.error_text === UNSUPPORTED_SUMMARY_CONTENT_MESSAGE) || + (summaryAttempt?.status === 'failed' && summaryAttempt.error_text === UNSUPPORTED_SUMMARY_CONTENT_MESSAGE) || summaryError === UNSUPPORTED_SUMMARY_CONTENT_MESSAGE if (isPublicSession && !parsed) return null return ( @@ -649,9 +652,9 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se

{UNSUPPORTED_SUMMARY_CONTENT_DETAIL}

)} - {summary?.status === 'failed' && summary.error_text && !unsupportedContent && !isPublicSession && ( + {summaryAttempt?.status === 'failed' && summaryAttempt.error_text && !unsupportedContent && !isPublicSession && (

- {summary.error_text} + {summaryAttempt.error_text}

)} {summaryError && !unsupportedContent && (