Files
clawmates/crates/cm-db/src/repo/research_outcomes.rs
T
Omar Sobh e3ef3fd056
ci / gates (push) Successful in 15s
ci / frontend (push) Successful in 26s
ci / rust (push) Successful in 3m58s
ci / e2e (push) Skipped
ci / publish (push) Successful in 3m46s
research: fix Draft 'Invalid Date' + stale-error leak in pipeline card
Two cosmetic bugs surfaced by the successful v0.8.3 pipeline run:

1. **'produced Invalid Date'** — research_outcomes.created_at was
   an OffsetDateTime serialized by time's default array format
   (`[y, ordinal, hh, mm, ss, ns, tz]`), which browser's
   `new Date(...)` can't parse. Add `#[serde(with =
   "time::serde::rfc3339")]` matching the pattern already in
   threads.rs / routine_runs.rs.

2. **Stale error text on pipeline card** — the runs stage's
   `latest_error` walked every run by `created_at DESC` and
   returned the first non-empty error, so a topic with an earlier
   failed run + a later completed run kept displaying the old
   error next to '1 completed'. Now the error only surfaces when
   the MOST RECENT run itself failed. Historical failures stay in
   the run count but don't leak their message.
2026-07-16 15:59:04 -07:00

87 lines
2.8 KiB
Rust

//! Persisted research artifacts — one row per run's final synthesis. When
//! `topology_worker` completes a run tagged with a `research_topic_id`, it
//! extracts the orchestrator's `RunRecord.final_output` and calls
//! [`insert`] here. The frontend canvas then renders the latest outcome
//! instead of the topic description when the topic has moved past
//! `standby`, so reviewers see the actual draft.
//!
//! Version is per-topic and monotonically increasing so reject-with-
//! revision loops accumulate history rather than clobber prior drafts.
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use time::OffsetDateTime;
use uuid::Uuid;
use crate::DbError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Outcome {
pub id: Uuid,
pub topic_id: Uuid,
pub version: i32,
pub body_md: String,
pub produced_by_run_id: Option<Uuid>,
// RFC3339 on the wire so `new Date(...)` in the browser parses it
// instead of choking on the `time` crate's default `[y, ordinal,
// ...]` array format (surfaced as "Invalid Date" in the Draft
// header).
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
}
/// Insert a new outcome. Version is derived server-side as `max(version) + 1`
/// for the topic (starting at 1) so callers never need to know the current
/// count. Returns the persisted row.
pub async fn insert(
pool: &PgPool,
topic_id: Uuid,
body_md: &str,
produced_by_run_id: Option<Uuid>,
) -> Result<Outcome, DbError> {
let id = Uuid::now_v7();
let row = sqlx::query!(
"INSERT INTO research_outcomes (id, topic_id, version, body_md, produced_by_run_id)
SELECT $1, $2, coalesce(max(version), 0) + 1, $3, $4
FROM research_outcomes
WHERE topic_id = $2
RETURNING id, topic_id, version, body_md, produced_by_run_id, created_at",
id,
topic_id,
body_md,
produced_by_run_id,
)
.fetch_one(pool)
.await?;
Ok(Outcome {
id: row.id,
topic_id: row.topic_id,
version: row.version,
body_md: row.body_md,
produced_by_run_id: row.produced_by_run_id,
created_at: row.created_at,
})
}
/// Newest outcome for a topic, or `None` if no run has completed yet.
pub async fn latest(pool: &PgPool, topic_id: Uuid) -> Result<Option<Outcome>, DbError> {
let row = sqlx::query!(
"SELECT id, topic_id, version, body_md, produced_by_run_id, created_at
FROM research_outcomes
WHERE topic_id = $1
ORDER BY version DESC
LIMIT 1",
topic_id,
)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| Outcome {
id: r.id,
topic_id: r.topic_id,
version: r.version,
body_md: r.body_md,
produced_by_run_id: r.produced_by_run_id,
created_at: r.created_at,
}))
}