Files
clawmates/crates/cm-db/src/repo/missions.rs
T
Omar SobhandClaude Opus 5 0785ac9c79 feat(missions): document reader + three-tab IA for the mission page
The mission page made its own output unreadable. Reviewing a research
brief meant scrolling a 300px <pre> nested inside a 260px run box nested
inside the page scroller (plus a 4th scroll region for the description) —
and the text was capped at 6,000 chars server-side with no way to fetch
the rest, so a 53kB brief showed ~11% of itself and silently dropped the
remainder. Eight flat tabs (overview/phases/tasks/team/live/artifacts/
benchmarks/pane) mixed lifecycle, work items, people, telemetry, outputs
and infra at one level, so nothing indicated where the deliverable lived.

Reader:
- GET /api/missions/{id}/documents lists every agent output (titles +
  sizes, no bodies); GET .../documents/{run_id}/{index} returns one in
  full. Scoped to the mission so a run id from elsewhere can't be read.
- MissionOutputReader: rail (documents grouped by phase) · document ·
  outline (headings, click to jump). Exactly one scroll container per
  column, never nested. Copy + download .md.
- MarkdownBlock gains fenced code blocks (agent output is full of ```rust,
  previously mangled into paragraphs), h4-h6, heading anchors, and an
  outlineOf() helper.

Information architecture:
- Three primary tabs with shallow sub-views: RUN (phases/tasks/live) ·
  OUTPUT (documents/artifacts/benchmarks) · SETUP (overview/team/pane).
- PhaseRunsList shows a short excerpt with no inner scrollbar and points
  at the reader for the full text.
- The header description is clipped, not scrollable; its full text now
  has a home in Setup → Overview.

Missions list:
- /api/missions returns MissionListItem — Mission flattened plus
  phases_total/phases_done/current_phase, so the JSON stays a strict
  superset. Cards render a progress bar and "Coding · 1/2" instead of a
  bare status dot.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 15:16:14 +02:00

785 lines
26 KiB
Rust

//! Missions — the unified top-level workflow entity introduced in
//! Slice 1 of the research/loops consolidation.
//!
//! A mission composes one or more [`MissionPhase`]s. Existing
//! `research_topics` and `loops` rows are shadow-migrated into
//! `missions` + `mission_phases` by migration 0047 and by continued
//! writes from the compat shims (Slice 2). Old tables stay live and
//! writable until Slice 9's big-bang cutover.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::PgPool;
use time::OffsetDateTime;
use uuid::Uuid;
use crate::DbError;
// ── Types ────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Mission {
pub id: Uuid,
pub workspace_id: Uuid,
pub title: String,
pub template_kind: String,
pub team_id: Option<Uuid>,
pub team_template_id: Option<Uuid>,
pub repo_id: Option<Uuid>,
pub schedule: Value,
pub status: String,
pub description: Option<String>,
pub config: Value,
/// 'zeroclaw' (default, headless) | 'local_herdr' (attended, fleet node)
pub runtime_kind: String,
/// FK → nodes(id); only relevant when runtime_kind = 'local_herdr'
pub target_node_id: Option<Uuid>,
/// Per-mission ZeroClaw runtime container name (C3 workspace isolation).
/// Null until `mission_runtime::ensure_container` provisions it.
pub runtime_container_name: Option<String>,
/// Gateway URL the topology_worker dials for this mission's runs.
pub runtime_endpoint: Option<String>,
/// One-time pairing code captured from the fresh gateway's startup
/// log. topology_worker uses it to lazy-pair with this specific
/// mission runtime instead of the shared-runtime env token.
pub runtime_pairing_code: Option<String>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339::option")]
pub completed_at: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MissionPhase {
pub id: Uuid,
pub mission_id: Uuid,
pub kind: String,
pub order_idx: i32,
pub status: String,
pub config: Value,
#[serde(with = "time::serde::rfc3339::option")]
pub started_at: Option<OffsetDateTime>,
#[serde(with = "time::serde::rfc3339::option")]
pub completed_at: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MissionTask {
pub id: Uuid,
pub mission_id: Uuid,
pub phase_id: Uuid,
pub external_id: Option<String>,
pub title: String,
pub assigned_agent_id: Option<Uuid>,
pub status: String,
pub run_id: Option<Uuid>,
pub artifact_paths: Vec<String>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339::option")]
pub completed_at: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MissionArtifact {
pub id: Uuid,
pub mission_id: Uuid,
pub phase_id: Option<Uuid>,
pub path: String,
pub kind: String,
pub mime: Option<String>,
pub generated_by_run: Option<Uuid>,
pub rendered_pdf_path: Option<String>,
pub render_pdf_status: String,
pub render_pdf_error: Option<String>,
pub title: Option<String>,
pub metadata: Value,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
}
/// Convenience payload for creating a mission from the wizard.
/// Callers pass `phases` describing the desired plan; the create call
/// inserts the mission row + the corresponding mission_phase rows in
/// one transaction so partial creates never leak.
#[derive(Debug, Clone)]
pub struct NewMission<'a> {
pub workspace_id: Uuid,
pub title: &'a str,
pub template_kind: &'a str,
pub team_id: Option<Uuid>,
pub team_template_id: Option<Uuid>,
pub repo_id: Option<Uuid>,
pub schedule: Value,
pub description: Option<&'a str>,
pub config: Value,
/// Defaults to 'zeroclaw' when None.
pub runtime_kind: Option<&'a str>,
pub target_node_id: Option<Uuid>,
pub phases: Vec<NewMissionPhase>,
}
#[derive(Debug, Clone)]
pub struct NewMissionPhase {
pub kind: String,
pub order_idx: i32,
pub config: Value,
}
// ── Missions ─────────────────────────────────────────────────────
/// Insert a mission + its phases in a single transaction.
/// Returns the newly-minted mission id.
pub async fn insert(pool: &PgPool, m: NewMission<'_>) -> Result<Uuid, DbError> {
let mission_id = Uuid::now_v7();
let mut tx = pool.begin().await?;
sqlx::query(
"INSERT INTO missions
(id, workspace_id, title, template_kind, team_id,
team_template_id, repo_id, schedule, status, description, config,
runtime_kind, target_node_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'draft',$9,$10,
COALESCE($11,'zeroclaw'),$12)",
)
.bind(mission_id)
.bind(m.workspace_id)
.bind(m.title)
.bind(m.template_kind)
.bind(m.team_id)
.bind(m.team_template_id)
.bind(m.repo_id)
.bind(&m.schedule)
.bind(m.description)
.bind(&m.config)
.bind(m.runtime_kind)
.bind(m.target_node_id)
.execute(&mut *tx)
.await?;
for p in &m.phases {
sqlx::query(
"INSERT INTO mission_phases
(id, mission_id, kind, order_idx, status, config)
VALUES ($1,$2,$3,$4,'pending',$5)",
)
.bind(Uuid::now_v7())
.bind(mission_id)
.bind(&p.kind)
.bind(p.order_idx)
.bind(&p.config)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(mission_id)
}
/// Workspace-scoped fetch — returns None when the id belongs to a
/// different workspace so callers can't leak cross-workspace metadata.
pub async fn get(pool: &PgPool, id: Uuid, workspace_id: Uuid) -> Result<Option<Mission>, DbError> {
use sqlx::Row;
let row = sqlx::query(
"SELECT id, workspace_id, title, template_kind, team_id,
team_template_id, repo_id, schedule, status,
description, config, runtime_kind, target_node_id,
runtime_container_name, runtime_endpoint, runtime_pairing_code,
created_at, updated_at, completed_at
FROM missions WHERE id = $1 AND workspace_id = $2",
)
.bind(id)
.bind(workspace_id)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| Mission {
id: r.get("id"),
workspace_id: r.get("workspace_id"),
title: r.get("title"),
template_kind: r.get("template_kind"),
team_id: r.get("team_id"),
team_template_id: r.get("team_template_id"),
repo_id: r.get("repo_id"),
schedule: r.get("schedule"),
status: r.get("status"),
description: r.get("description"),
config: r.get("config"),
runtime_kind: r.get("runtime_kind"),
target_node_id: r.get("target_node_id"),
runtime_container_name: r.get("runtime_container_name"),
runtime_endpoint: r.get("runtime_endpoint"),
runtime_pairing_code: r.get("runtime_pairing_code"),
created_at: r.get("created_at"),
updated_at: r.get("updated_at"),
completed_at: r.get("completed_at"),
}))
}
/// List missions for a workspace, newest-first.
pub async fn list_by_workspace(
pool: &PgPool,
workspace_id: Uuid,
limit: i64,
) -> Result<Vec<Mission>, DbError> {
use sqlx::Row;
let rows = sqlx::query(
"SELECT id, workspace_id, title, template_kind, team_id,
team_template_id, repo_id, schedule, status,
description, config, runtime_kind, target_node_id,
runtime_container_name, runtime_endpoint, runtime_pairing_code,
created_at, updated_at, completed_at
FROM missions WHERE workspace_id = $1
ORDER BY created_at DESC LIMIT $2",
)
.bind(workspace_id)
.bind(limit)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| Mission {
id: r.get("id"),
workspace_id: r.get("workspace_id"),
title: r.get("title"),
template_kind: r.get("template_kind"),
team_id: r.get("team_id"),
team_template_id: r.get("team_template_id"),
repo_id: r.get("repo_id"),
schedule: r.get("schedule"),
status: r.get("status"),
description: r.get("description"),
config: r.get("config"),
runtime_kind: r.get("runtime_kind"),
target_node_id: r.get("target_node_id"),
runtime_container_name: r.get("runtime_container_name"),
runtime_endpoint: r.get("runtime_endpoint"),
runtime_pairing_code: r.get("runtime_pairing_code"),
created_at: r.get("created_at"),
updated_at: r.get("updated_at"),
completed_at: r.get("completed_at"),
})
.collect())
}
pub async fn set_description(
pool: &PgPool,
id: Uuid,
workspace_id: Uuid,
description: &str,
) -> Result<(), DbError> {
sqlx::query(
"UPDATE missions
SET description = $3, updated_at = now()
WHERE id = $1 AND workspace_id = $2",
)
.bind(id)
.bind(workspace_id)
.bind(description)
.execute(pool)
.await?;
Ok(())
}
/// Patch title + description in one shot. Either field `None` = leave
/// as-is (uses COALESCE so partial edits don't clobber the other).
pub async fn update_meta(
pool: &PgPool,
id: Uuid,
workspace_id: Uuid,
title: Option<&str>,
description: Option<&str>,
) -> Result<(), DbError> {
sqlx::query(
"UPDATE missions
SET title = COALESCE($3, title),
description = COALESCE($4, description),
updated_at = now()
WHERE id = $1 AND workspace_id = $2",
)
.bind(id)
.bind(workspace_id)
.bind(title)
.bind(description)
.execute(pool)
.await?;
Ok(())
}
/// Bind a mission to its per-mission runtime container + endpoint.
/// Called by `mission_runtime::ensure_container` after the docker
/// container is running. Null endpoint clears the binding (used by
/// the teardown sweeper).
pub async fn set_runtime_binding(
pool: &PgPool,
id: Uuid,
workspace_id: Uuid,
container_name: Option<&str>,
endpoint: Option<&str>,
pairing_code: Option<&str>,
) -> Result<(), DbError> {
sqlx::query(
"UPDATE missions
SET runtime_container_name = $3,
runtime_endpoint = $4,
runtime_pairing_code = $5,
updated_at = now()
WHERE id = $1 AND workspace_id = $2",
)
.bind(id)
.bind(workspace_id)
.bind(container_name)
.bind(endpoint)
.bind(pairing_code)
.execute(pool)
.await?;
Ok(())
}
/// Hard-delete a mission. Cascades via FKs on mission_phases /
/// mission_tasks / mission_artifacts / benchmark_snapshots (all
/// declared ON DELETE CASCADE in 0047).
pub async fn delete(pool: &PgPool, id: Uuid, workspace_id: Uuid) -> Result<u64, DbError> {
let r = sqlx::query("DELETE FROM missions WHERE id = $1 AND workspace_id = $2")
.bind(id)
.bind(workspace_id)
.execute(pool)
.await?;
Ok(r.rows_affected())
}
pub async fn set_status(
pool: &PgPool,
id: Uuid,
workspace_id: Uuid,
status: &str,
) -> Result<(), DbError> {
let completed = matches!(status, "completed" | "failed" | "cancelled");
sqlx::query(
"UPDATE missions
SET status = $3,
updated_at = now(),
completed_at = CASE WHEN $4 THEN now() ELSE completed_at END
WHERE id = $1 AND workspace_id = $2",
)
.bind(id)
.bind(workspace_id)
.bind(status)
.bind(completed)
.execute(pool)
.await?;
Ok(())
}
// ── Phases ───────────────────────────────────────────────────────
pub async fn phases_for(pool: &PgPool, mission_id: Uuid) -> Result<Vec<MissionPhase>, DbError> {
use sqlx::Row;
let rows = sqlx::query(
"SELECT id, mission_id, kind, order_idx, status, config,
started_at, completed_at
FROM mission_phases WHERE mission_id = $1
ORDER BY order_idx ASC",
)
.bind(mission_id)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| MissionPhase {
id: r.get("id"),
mission_id: r.get("mission_id"),
kind: r.get("kind"),
order_idx: r.get("order_idx"),
status: r.get("status"),
config: r.get("config"),
started_at: r.get("started_at"),
completed_at: r.get("completed_at"),
})
.collect())
}
pub async fn set_phase_status(pool: &PgPool, phase_id: Uuid, status: &str) -> Result<(), DbError> {
let is_start = status == "running";
let is_end = matches!(status, "completed" | "failed" | "skipped");
sqlx::query(
"UPDATE mission_phases
SET status = $2,
started_at = CASE WHEN $3 AND started_at IS NULL THEN now() ELSE started_at END,
completed_at = CASE WHEN $4 THEN now() ELSE completed_at END
WHERE id = $1",
)
.bind(phase_id)
.bind(status)
.bind(is_start)
.bind(is_end)
.execute(pool)
.await?;
Ok(())
}
// ── Tasks ────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct UpsertTask<'a> {
pub mission_id: Uuid,
pub phase_id: Uuid,
pub external_id: &'a str,
pub title: &'a str,
pub assigned_agent_id: Option<Uuid>,
pub status: &'a str,
pub run_id: Option<Uuid>,
}
/// UPSERT a task keyed on (phase_id, external_id). Used by the
/// task-card parser (Slice 5) — same INT-XX marker across iterations
/// updates one row instead of creating duplicates.
pub async fn upsert_task(pool: &PgPool, t: UpsertTask<'_>) -> Result<Uuid, DbError> {
use sqlx::Row;
let is_completion = t.status == "complete";
let row = sqlx::query(
"INSERT INTO mission_tasks
(id, mission_id, phase_id, external_id, title,
assigned_agent_id, status, run_id, completed_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,
CASE WHEN $9 THEN now() ELSE NULL END)
ON CONFLICT (phase_id, external_id)
DO UPDATE SET
title = EXCLUDED.title,
assigned_agent_id = COALESCE(EXCLUDED.assigned_agent_id, mission_tasks.assigned_agent_id),
status = EXCLUDED.status,
run_id = COALESCE(EXCLUDED.run_id, mission_tasks.run_id),
updated_at = now(),
completed_at = CASE WHEN $9 AND mission_tasks.completed_at IS NULL
THEN now()
ELSE mission_tasks.completed_at END
RETURNING id",
)
.bind(Uuid::now_v7())
.bind(t.mission_id)
.bind(t.phase_id)
.bind(t.external_id)
.bind(t.title)
.bind(t.assigned_agent_id)
.bind(t.status)
.bind(t.run_id)
.bind(is_completion)
.fetch_one(pool)
.await?;
Ok(row.get("id"))
}
pub async fn tasks_for(pool: &PgPool, mission_id: Uuid) -> Result<Vec<MissionTask>, DbError> {
use sqlx::Row;
let rows = sqlx::query(
"SELECT id, mission_id, phase_id, external_id, title,
assigned_agent_id, status, run_id, artifact_paths,
created_at, updated_at, completed_at
FROM mission_tasks WHERE mission_id = $1
ORDER BY created_at ASC",
)
.bind(mission_id)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| MissionTask {
id: r.get("id"),
mission_id: r.get("mission_id"),
phase_id: r.get("phase_id"),
external_id: r.get("external_id"),
title: r.get("title"),
assigned_agent_id: r.get("assigned_agent_id"),
status: r.get("status"),
run_id: r.get("run_id"),
artifact_paths: r.get("artifact_paths"),
created_at: r.get("created_at"),
updated_at: r.get("updated_at"),
completed_at: r.get("completed_at"),
})
.collect())
}
// ── Artifacts ────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct RegisterArtifact<'a> {
pub mission_id: Uuid,
pub phase_id: Option<Uuid>,
pub path: &'a str,
pub kind: &'a str,
pub mime: Option<&'a str>,
pub title: Option<&'a str>,
pub generated_by_run: Option<Uuid>,
pub render_pdf: bool,
}
/// Register an artifact discovered on disk (or produced inline).
/// Idempotent on (mission_id, path).
pub async fn register_artifact(pool: &PgPool, a: RegisterArtifact<'_>) -> Result<Uuid, DbError> {
use sqlx::Row;
let render_status = if a.render_pdf { "pending" } else { "skip" };
let row = sqlx::query(
"INSERT INTO mission_artifacts
(id, mission_id, phase_id, path, kind, mime, title,
generated_by_run, render_pdf_status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT (mission_id, path) DO UPDATE
SET kind = EXCLUDED.kind,
mime = COALESCE(EXCLUDED.mime, mission_artifacts.mime),
title = COALESCE(EXCLUDED.title, mission_artifacts.title),
generated_by_run = COALESCE(EXCLUDED.generated_by_run,
mission_artifacts.generated_by_run),
updated_at = now()
RETURNING id",
)
.bind(Uuid::now_v7())
.bind(a.mission_id)
.bind(a.phase_id)
.bind(a.path)
.bind(a.kind)
.bind(a.mime)
.bind(a.title)
.bind(a.generated_by_run)
.bind(render_status)
.fetch_one(pool)
.await?;
Ok(row.get("id"))
}
pub async fn artifacts_for(
pool: &PgPool,
mission_id: Uuid,
) -> Result<Vec<MissionArtifact>, DbError> {
use sqlx::Row;
let rows = sqlx::query(
"SELECT id, mission_id, phase_id, path, kind, mime,
generated_by_run, rendered_pdf_path, render_pdf_status,
render_pdf_error, title, metadata, created_at, updated_at
FROM mission_artifacts WHERE mission_id = $1
ORDER BY created_at DESC",
)
.bind(mission_id)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| MissionArtifact {
id: r.get("id"),
mission_id: r.get("mission_id"),
phase_id: r.get("phase_id"),
path: r.get("path"),
kind: r.get("kind"),
mime: r.get("mime"),
generated_by_run: r.get("generated_by_run"),
rendered_pdf_path: r.get("rendered_pdf_path"),
render_pdf_status: r.get("render_pdf_status"),
render_pdf_error: r.get("render_pdf_error"),
title: r.get("title"),
metadata: r.get("metadata"),
created_at: r.get("created_at"),
updated_at: r.get("updated_at"),
})
.collect())
}
/// PDF renderer worker (Slice 6) picks a pending artifact off the
/// queue. Skips the FOR UPDATE dance until we see concurrent renderers
/// — one renderer per process is fine for the first cut.
pub async fn next_pdf_pending(pool: &PgPool, limit: i64) -> Result<Vec<MissionArtifact>, DbError> {
use sqlx::Row;
let rows = sqlx::query(
"SELECT id, mission_id, phase_id, path, kind, mime,
generated_by_run, rendered_pdf_path, render_pdf_status,
render_pdf_error, title, metadata, created_at, updated_at
FROM mission_artifacts
WHERE render_pdf_status = 'pending'
ORDER BY created_at ASC
LIMIT $1",
)
.bind(limit)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| MissionArtifact {
id: r.get("id"),
mission_id: r.get("mission_id"),
phase_id: r.get("phase_id"),
path: r.get("path"),
kind: r.get("kind"),
mime: r.get("mime"),
generated_by_run: r.get("generated_by_run"),
rendered_pdf_path: r.get("rendered_pdf_path"),
render_pdf_status: r.get("render_pdf_status"),
render_pdf_error: r.get("render_pdf_error"),
title: r.get("title"),
metadata: r.get("metadata"),
created_at: r.get("created_at"),
updated_at: r.get("updated_at"),
})
.collect())
}
pub async fn set_pdf_result(
pool: &PgPool,
id: Uuid,
rendered_pdf_path: Option<&str>,
error: Option<&str>,
) -> Result<(), DbError> {
let status = if error.is_some() { "failed" } else { "done" };
sqlx::query(
"UPDATE mission_artifacts
SET rendered_pdf_path = COALESCE($2, rendered_pdf_path),
render_pdf_status = $3,
render_pdf_error = $4,
updated_at = now()
WHERE id = $1",
)
.bind(id)
.bind(rendered_pdf_path)
.bind(status)
.bind(error)
.execute(pool)
.await?;
Ok(())
}
// ── Benchmark snapshots (Slice 7) ────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BenchmarkSnapshot {
pub id: Uuid,
pub mission_id: Uuid,
pub phase_id: Uuid,
pub iteration: i32,
pub before_metrics: Option<Value>,
pub after_metrics: Option<Value>,
pub delta: Option<Value>,
pub driver: Option<String>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
}
#[derive(Debug, Clone)]
pub struct UpsertBenchmarkSnapshot<'a> {
pub mission_id: Uuid,
pub phase_id: Uuid,
pub iteration: i32,
pub before_metrics: Option<&'a Value>,
pub after_metrics: Option<&'a Value>,
pub delta: Option<&'a Value>,
pub driver: Option<&'a str>,
}
/// UPSERT a snapshot keyed on (phase_id, iteration). Baseline pass
/// uses iteration=0 with before_metrics only; each post-iteration
/// call updates the same row with after_metrics + delta so the pair
/// stays coherent for the canvas's side-by-side render.
pub async fn upsert_benchmark_snapshot(
pool: &PgPool,
s: UpsertBenchmarkSnapshot<'_>,
) -> Result<Uuid, DbError> {
use sqlx::Row;
let row = sqlx::query(
"INSERT INTO benchmark_snapshots
(id, mission_id, phase_id, iteration,
before_metrics, after_metrics, delta, driver)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (phase_id, iteration) DO UPDATE SET
-- Preserve non-NULL prior values so a baseline call
-- doesn't wipe the after_metrics from a previous run.
before_metrics = COALESCE(EXCLUDED.before_metrics, benchmark_snapshots.before_metrics),
after_metrics = COALESCE(EXCLUDED.after_metrics, benchmark_snapshots.after_metrics),
delta = COALESCE(EXCLUDED.delta, benchmark_snapshots.delta),
driver = COALESCE(EXCLUDED.driver, benchmark_snapshots.driver)
RETURNING id",
)
.bind(Uuid::now_v7())
.bind(s.mission_id)
.bind(s.phase_id)
.bind(s.iteration)
.bind(s.before_metrics)
.bind(s.after_metrics)
.bind(s.delta)
.bind(s.driver)
.fetch_one(pool)
.await?;
Ok(row.get("id"))
}
pub async fn benchmark_snapshots_for(
pool: &PgPool,
mission_id: Uuid,
) -> Result<Vec<BenchmarkSnapshot>, DbError> {
use sqlx::Row;
let rows = sqlx::query(
"SELECT id, mission_id, phase_id, iteration,
before_metrics, after_metrics, delta, driver, created_at
FROM benchmark_snapshots
WHERE mission_id = $1
ORDER BY phase_id, iteration",
)
.bind(mission_id)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| BenchmarkSnapshot {
id: r.get("id"),
mission_id: r.get("mission_id"),
phase_id: r.get("phase_id"),
iteration: r.get("iteration"),
before_metrics: r.get("before_metrics"),
after_metrics: r.get("after_metrics"),
delta: r.get("delta"),
driver: r.get("driver"),
created_at: r.get("created_at"),
})
.collect())
}
/// Phase progress for a set of missions, for the missions list cards.
/// Returns `(mission_id, total, done, running_phase_kind)`.
///
/// A status dot alone doesn't tell you where a mission actually is; this
/// is what lets a card say "Coding · 1/2" instead of just "running".
pub async fn phase_progress(
pool: &PgPool,
mission_ids: &[Uuid],
) -> Result<Vec<(Uuid, i64, i64, Option<String>)>, DbError> {
use sqlx::Row;
if mission_ids.is_empty() {
return Ok(Vec::new());
}
let rows = sqlx::query(
"SELECT mission_id,
count(*) AS total,
count(*) FILTER (WHERE status IN ('completed','skipped')) AS done,
(array_agg(kind ORDER BY order_idx)
FILTER (WHERE status = 'running'))[1] AS running_kind
FROM mission_phases
WHERE mission_id = ANY($1)
GROUP BY mission_id",
)
.bind(mission_ids)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| {
(
r.get("mission_id"),
r.get("total"),
r.get("done"),
r.get("running_kind"),
)
})
.collect())
}