loops: N/M INTs progress pill on source-bound loop cards
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 27s
ci / rust (push) Failing after 41s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

Users couldn't see how a research-bound loop was progressing through
its integration plan — the consumed_int_ids state existed in the DB
but nothing surfaced it. Now each loop card in the sidebar shows a
cyan pill "3/8 INTs" + source topic title + a thin progress bar, when
the loop is bound to a research topic.

Backend:
- GET /api/loops/progress — bulk read for every loop with a source
  topic. Returns {loop_id, source_topic_id, source_topic_title,
  source_outcome_version, consumed_count, total_int_count,
  current_int_index} per loop. Standalone loops are omitted.
- count_int_ids parses unique INT-<number> ids out of the source
  outcome's markdown — same permissive matcher as the completion
  hook, so what the pill counts matches what the loop can advance.
- Memoized by topic_id inside the endpoint so N loops sharing 1
  source topic only fetch the outcome once.

Frontend:
- listLoopProgress helper + LoopProgress type in the loops API.
- LoopsList fetches loops + progress in parallel on mount.
- Each card looks up progress by loop_id and, when found, renders
  under the schedule line: pill "3/8 INTs · Topic title" with a
  3px cyan progress bar. Title hover shows artifact version.

Follow-ups:
- Refresh button on the card — re-snapshot artifact into
  task_template (cosmetic; the enqueue path already reads latest).
- Reorder rationale extraction — parse "REORDER: <text>" out of run
  output, index as a per-loop event log for a mini-timeline UI.
This commit is contained in:
Omar Sobh
2026-07-09 18:57:09 -07:00
parent 4a140cb7db
commit a34be33261
4 changed files with 197 additions and 2 deletions
+100
View File
@@ -351,6 +351,106 @@ pub async fn get_loop(
Ok(Json(hydrate_staffing(&state.pool, inner).await?))
}
#[derive(Serialize)]
pub struct LoopProgress {
pub loop_id: Uuid,
pub source_topic_id: Uuid,
pub source_topic_title: String,
pub source_outcome_version: i32,
pub consumed_count: usize,
pub total_int_count: usize,
pub current_int_index: i32,
}
/// `GET /api/loops/progress` — bulk progress read for every loop in the
/// workspace that's bound to a research topic. Skips standalone loops
/// entirely (empty entry). Parses INT-XX ids from the source outcome's
/// markdown to compute the total; consumed count comes straight from
/// `consumed_int_ids`. Used by the loops sidebar to render an
/// "N/M INTs" pill on each source-bound card.
///
/// Cost: one query for the loops list + one outcome fetch per unique
/// source topic (memoized in the loop below). No N+1 on the topic
/// lookup when many loops share a source.
pub async fn list_progress(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Vec<LoopProgress>>, ApiError> {
let loops = cm_db::repo::loops::list(&state.pool, user.workspace_id.as_uuid()).await?;
let mut by_topic: std::collections::HashMap<Uuid, (String, i32, usize)> =
std::collections::HashMap::new();
let mut out = Vec::new();
for l in &loops {
let ctx = match cm_db::repo::loops::source_research_context(&state.pool, l.id).await {
Ok(Some(c)) => c,
_ => continue,
};
let (topic_id, consumed, current_idx) = ctx;
let (title, version, total) = match by_topic.get(&topic_id) {
Some(cached) => cached.clone(),
None => {
// Ownership check via get + then count INTs in the latest
// outcome. Any failure downgrades to (title, 0, 0) so the
// pill still renders — showing 3/0 is better than 500ing
// the whole list.
let topic = match cm_db::repo::research_topics::get(
&state.pool,
topic_id,
user.workspace_id.as_uuid(),
)
.await
{
Ok(Some(t)) => t,
_ => continue,
};
let outcome = cm_db::repo::research_outcomes::latest(&state.pool, topic_id)
.await
.unwrap_or(None);
let (version, total) = match &outcome {
Some(o) => (o.version, count_int_ids(&o.body_md)),
None => (0, 0),
};
let cached = (topic.title.clone(), version, total);
by_topic.insert(topic_id, cached.clone());
cached
}
};
out.push(LoopProgress {
loop_id: l.id,
source_topic_id: topic_id,
source_topic_title: title,
source_outcome_version: version,
consumed_count: consumed.len(),
total_int_count: total,
current_int_index: current_idx,
});
}
Ok(Json(out))
}
/// Count unique INT-<number> ids in a markdown blob. Case-insensitive,
/// tolerates prefixes like `### INT-01` and inline references. Same
/// permissive matcher used by the completion-marker parser, so what the
/// pill counts matches what the completion path can advance against.
fn count_int_ids(text: &str) -> usize {
let upper = text.to_ascii_uppercase();
let mut seen = std::collections::HashSet::new();
let mut i = 0;
while let Some(pos) = upper[i..].find("INT-") {
let start = i + pos + 4;
let end = start
+ upper[start..]
.chars()
.take_while(|c| c.is_ascii_digit())
.count();
if end > start {
seen.insert(upper[start..end].parse::<u32>().ok());
}
i = end.max(i + pos + 4);
}
seen.into_iter().flatten().count()
}
#[derive(Deserialize)]
pub struct UpdateLoopRequest {
pub title: String,