slice 1: missions data model + migration
Introduce the unified `missions` tier that will replace the current
research_topics + loops split. This slice ships the data model +
backfill + skeleton REST surface; the old wizards keep working in
parallel until Slice 9's big-bang cutover.
Migration 0047 adds:
- missions (top-level workflow: template_kind + team +
schedule + status + config)
- mission_phases (ordered {research|coding|benchmark|
security_scan} phases per mission)
- mission_tasks (typed units of work, e.g. INT-XX cards,
UPSERT-keyed on (phase_id, external_id))
- mission_artifacts (MD/PDF/benchmark/security/diff files with
a pending queue for the PDF renderer worker)
- benchmark_snapshots (before/after pairs per iteration)
Backfill copies existing research_topics + loops rows into the new
tables as one-shot missions with the appropriate template_kind, so
Slice 2's UI can render the full history immediately.
New Rust surface:
- cm_domain: MissionId, MissionPhaseId, MissionTaskId, MissionArtifactId
- cm_db::repo::missions: Mission/MissionPhase/MissionTask/
MissionArtifact structs + insert (txn-wrapped)/get/list/set_status/
phases_for/set_phase_status/upsert_task/tasks_for/register_artifact/
artifacts_for/next_pdf_pending/set_pdf_result
- cm_api::routes::missions: skeleton list/create/get/set_status
routes registered at /api/missions/*
Follow-up slices layer richer behavior (template dispatch, phase
execution, task parsing, artifact rendering) on this foundation.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
68d84bf1ad
commit
fbefc67878
@@ -0,0 +1,545 @@
|
||||
//! 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,
|
||||
#[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,
|
||||
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)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'draft',$9,$10)",
|
||||
)
|
||||
.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)
|
||||
.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, 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"),
|
||||
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, 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"),
|
||||
created_at: r.get("created_at"),
|
||||
updated_at: r.get("updated_at"),
|
||||
completed_at: r.get("completed_at"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod fleet_beszel;
|
||||
pub mod fleet_tailscale;
|
||||
pub mod loops;
|
||||
pub mod messages;
|
||||
pub mod missions;
|
||||
pub mod node_metrics;
|
||||
pub mod node_rules;
|
||||
pub mod node_tools;
|
||||
|
||||
Reference in New Issue
Block a user