Bring quire-ci schema design to quire-server
Replace the mutable state+failure_kind columns with timestamp-based
lifecycle and a single outcome enum, following the schema designed
for quire-ci.
Migration 0010 renames:
queued_at_ms → created_at
started_at_ms → dispatched_at
finished_at_ms → resolved_at
And replaces state+failure_kind with outcome (NULL while running,
set on resolution): succeeded, failed-pipeline, failed-orphaned,
failed-internal, superseded.
RunState enum is removed from run.rs. Run now tracks dispatched/resolved
boolean flags derived from timestamps. transition() is replaced by
dispatch() and resolve(outcome). reconcile_orphans() and cancel_existing()
write timestamps+outcome directly. The bootstrap API checks dispatched_at
IS NULL instead of state = 'queued'.
Web layer (db.rs, templates.rs, handlers.rs, api.rs) updated to use new
column names. state() is now a derived method on template structs.
dev.rs seed fixtures updated to use outcome values.
https://claude.ai/code/session_0171wuJy2GRJZuj2oVYZ2iKe
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 9bf86bf..7418155 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -1,4 +1,3 @@
-mod quire;
mod sink;
use std::cell::RefCell;
diff --git a/quire-server/migrations/0010_outcome_schema.sql b/quire-server/migrations/0010_outcome_schema.sql
new file mode 100644
index 0000000..e0e9b7d
--- /dev/null
+++ b/quire-server/migrations/0010_outcome_schema.sql
@@ -0,0 +1,72 @@
+-- Replace state/failure_kind columns with timestamp-based lifecycle and
+-- outcome enum. Column renames:
+-- queued_at_ms → created_at
+-- started_at_ms → dispatched_at
+-- finished_at_ms → resolved_at
+--
+-- Lifecycle is now derived from whether timestamps are set:
+-- queued: dispatched_at IS NULL AND resolved_at IS NULL
+-- active: dispatched_at IS NOT NULL AND resolved_at IS NULL
+-- resolved: resolved_at IS NOT NULL (outcome IS NOT NULL)
+--
+-- outcome replaces state+failure_kind for resolved runs:
+-- state='succeeded' → 'succeeded'
+-- state='failed', failure_kind='orphaned' → 'failed-orphaned'
+-- state='failed', failure_kind='process-crashed' → 'failed-internal'
+-- state='failed' (other) → 'failed-pipeline'
+-- state='canceled' → 'superseded'
+
+CREATE TABLE runs_new (
+ id TEXT PRIMARY KEY,
+ repo TEXT NOT NULL,
+ ref_name TEXT NOT NULL,
+ sha TEXT NOT NULL,
+ pushed_at_ms INTEGER NOT NULL,
+ created_at INTEGER NOT NULL,
+ dispatched_at INTEGER,
+ resolved_at INTEGER,
+ outcome TEXT,
+ run_token TEXT,
+ git_dir TEXT,
+ traceparent TEXT,
+
+ CHECK (dispatched_at IS NULL OR dispatched_at >= created_at),
+ CHECK (resolved_at IS NULL OR resolved_at >= created_at),
+ CHECK (resolved_at IS NULL OR dispatched_at IS NULL
+ OR resolved_at >= dispatched_at),
+
+ CHECK ((resolved_at IS NULL) = (outcome IS NULL)),
+
+ CHECK (outcome IS NULL OR outcome IN (
+ 'succeeded',
+ 'failed-pipeline', 'failed-orphaned', 'failed-internal',
+ 'superseded'
+ ))
+);
+
+INSERT INTO runs_new
+ SELECT
+ id, repo, ref_name, sha, pushed_at_ms,
+ queued_at_ms,
+ started_at_ms,
+ finished_at_ms,
+ CASE
+ WHEN state IN ('queued', 'active') THEN NULL
+ WHEN state = 'succeeded' THEN 'succeeded'
+ WHEN state = 'failed' THEN
+ CASE failure_kind
+ WHEN 'orphaned' THEN 'failed-orphaned'
+ WHEN 'process-crashed' THEN 'failed-internal'
+ ELSE 'failed-pipeline'
+ END
+ WHEN state = 'canceled' THEN 'superseded'
+ ELSE NULL
+ END,
+ run_token, git_dir, traceparent
+ FROM runs;
+
+DROP TABLE runs;
+ALTER TABLE runs_new RENAME TO runs;
+
+CREATE INDEX runs_repo_pushed_at ON runs(repo, pushed_at_ms DESC);
+CREATE INDEX runs_pending ON runs(created_at) WHERE outcome IS NULL;
diff --git a/quire-server/src/bin/quire/commands/dev.rs b/quire-server/src/bin/quire/commands/dev.rs
index edf7247..527b864 100644
--- a/quire-server/src/bin/quire/commands/dev.rs
+++ b/quire-server/src/bin/quire/commands/dev.rs
@@ -17,14 +17,14 @@ pub fn seed() -> Result<Quire> {
}
/// One run with its jobs. `pushed_delta_ms` is offset from "now" at seed time;
-/// `started_delta_ms` is offset from `pushed`; `duration_ms` is how long the
-/// run ran after starting.
+/// `dispatched_delta_ms` is offset from `pushed`; `duration_ms` is how long the
+/// run ran after dispatching.
struct SeedRun {
- state: &'static str,
+ outcome: Option<&'static str>,
sha: &'static str,
ref_name: &'static str,
pushed_delta_ms: i64,
- started_delta_ms: Option<i64>,
+ dispatched_delta_ms: Option<i64>,
duration_ms: Option<i64>,
jobs: Vec<SeedJob>,
}
@@ -110,29 +110,29 @@ impl Seeder {
let run_id = Uuid::now_v7().to_string();
let pushed_at = self.base_ms + run.pushed_delta_ms;
- let started_at = run.started_delta_ms.map(|d| pushed_at + d);
- let finished_at = started_at.zip(run.duration_ms).map(|(s, d)| s + d);
+ let dispatched_at = run.dispatched_delta_ms.map(|d| pushed_at + d);
+ let resolved_at = dispatched_at.zip(run.duration_ms).map(|(s, d)| s + d);
self.db
.execute(
- "INSERT INTO runs (id, repo, ref_name, sha, pushed_at_ms, state, failure_kind,
- queued_at_ms, started_at_ms, finished_at_ms)
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9)",
+ "INSERT INTO runs (id, repo, ref_name, sha, pushed_at_ms,
+ created_at, dispatched_at, resolved_at, outcome)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
run_id,
repo,
run.ref_name,
run.sha,
pushed_at,
- run.state,
- pushed_at, // queued_at_ms = pushed_at_ms
- started_at,
- finished_at,
+ pushed_at, // created_at = pushed_at_ms
+ dispatched_at,
+ resolved_at,
+ run.outcome,
],
)
.into_diagnostic()?;
- let Some(run_started_at) = started_at else {
+ let Some(run_dispatched_at) = dispatched_at else {
return Ok(()); // queued run; no jobs to insert.
};
@@ -144,7 +144,7 @@ impl Seeder {
.join(&run_id);
for job in &run.jobs {
- let job_started_at = run_started_at + job.started_delta_ms;
+ let job_started_at = run_dispatched_at + job.started_delta_ms;
let job_finished_at = job.duration_ms.map(|d| job_started_at + d);
self.db
@@ -200,11 +200,11 @@ fn build_runs() -> Vec<SeedRun> {
vec![
// Run 1 — succeeded, all jobs passed.
SeedRun {
- state: "succeeded",
+ outcome: Some("succeeded"),
sha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
ref_name: "refs/heads/main",
pushed_delta_ms: 0,
- started_delta_ms: Some(1000),
+ dispatched_delta_ms: Some(1000),
duration_ms: Some(4000),
jobs: vec![
SeedJob {
@@ -248,11 +248,11 @@ fn build_runs() -> Vec<SeedRun> {
},
// Run 2 — failed, one job failed.
SeedRun {
- state: "failed",
+ outcome: Some("failed-pipeline"),
sha: "cafebabecafebabecafebabecafebabecafebabe",
ref_name: "refs/heads/main",
pushed_delta_ms: -600_000,
- started_delta_ms: Some(1000),
+ dispatched_delta_ms: Some(1000),
duration_ms: Some(7000),
jobs: vec![
SeedJob {
@@ -294,13 +294,13 @@ fn build_runs() -> Vec<SeedRun> {
},
],
},
- // Run 3 — canceled, pushed then rebased.
+ // Run 3 — superseded, pushed then rebased.
SeedRun {
- state: "canceled",
+ outcome: Some("superseded"),
sha: "1111111111111111111111111111111111111111",
ref_name: "refs/heads/feature",
pushed_delta_ms: -1_200_000,
- started_delta_ms: Some(1000),
+ dispatched_delta_ms: Some(1000),
duration_ms: Some(1000),
jobs: vec![SeedJob {
job_id: "build",
@@ -319,11 +319,11 @@ fn build_runs() -> Vec<SeedRun> {
},
// Run 4 — active, still running.
SeedRun {
- state: "active",
+ outcome: None,
sha: "2222222222222222222222222222222222222222",
ref_name: "refs/heads/main",
pushed_delta_ms: -5000,
- started_delta_ms: Some(1000),
+ dispatched_delta_ms: Some(1000),
duration_ms: None,
jobs: vec![SeedJob {
job_id: "build",
@@ -351,21 +351,21 @@ fn build_runs() -> Vec<SeedRun> {
},
// Run 5 — queued but not started.
SeedRun {
- state: "queued",
+ outcome: None,
sha: "3333333333333333333333333333333333333333",
ref_name: "refs/heads/main",
pushed_delta_ms: -1000,
- started_delta_ms: None,
+ dispatched_delta_ms: None,
duration_ms: None,
jobs: vec![],
},
// Run 6 — succeeded, multi-job: lint + build + test.
SeedRun {
- state: "succeeded",
+ outcome: Some("succeeded"),
sha: "4444444444444444444444444444444444444444",
ref_name: "refs/heads/v2",
pushed_delta_ms: -3_600_000,
- started_delta_ms: Some(2000),
+ dispatched_delta_ms: Some(2000),
duration_ms: Some(10_000),
jobs: vec![
SeedJob {
@@ -414,11 +414,11 @@ fn build_runs() -> Vec<SeedRun> {
},
// Run 7 — failed, orphaned (container died mid-run).
SeedRun {
- state: "failed",
+ outcome: Some("failed-orphaned"),
sha: "5555555555555555555555555555555555555555",
ref_name: "refs/heads/main",
pushed_delta_ms: -7_200_000,
- started_delta_ms: Some(1000),
+ dispatched_delta_ms: Some(1000),
duration_ms: Some(59_000),
jobs: vec![
SeedJob {
diff --git a/quire-server/src/ci/error.rs b/quire-server/src/ci/error.rs
index 8da2215..d966fb3 100644
--- a/quire-server/src/ci/error.rs
+++ b/quire-server/src/ci/error.rs
@@ -3,7 +3,6 @@
use miette::Diagnostic;
use super::pipeline::{CompileError, PipelineError};
-use super::run::RunState;
use super::runtime::RuntimeError;
use quire_core::fennel::FennelError;
use quire_core::secret;
@@ -22,8 +21,11 @@ pub enum Error {
#[diagnostic(transparent)]
Pipeline(Box<PipelineError>),
- #[error("invalid run transition: {from:?} -> {to:?}")]
- InvalidTransition { from: RunState, to: RunState },
+ #[error("run already dispatched")]
+ AlreadyDispatched,
+
+ #[error("run already resolved")]
+ AlreadyResolved,
#[error(transparent)]
Lua(Box<mlua::Error>),
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 6570021..d3cb6ce 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -11,7 +11,7 @@ pub use quire_core::ci::pipeline::{
pub use quire_core::ci::run::ApiSession;
pub use quire_core::ci::run::RunMeta;
pub use quire_core::ci::{pipeline, registration, runtime};
-pub use run::{Executor, Run, RunState, Runs, materialize_workspace, reconcile_orphans};
+pub use run::{Executor, Run, Runs, materialize_workspace, reconcile_orphans};
use tracing_opentelemetry::OpenTelemetrySpanExt as _;
/// A resolved commit reference.
@@ -508,22 +508,23 @@ exit 0
// The run should have reached succeeded.
let conn = crate::db::open(&quire.db_path()).expect("db");
- let state: String = conn
+ let outcome: Option<String> = conn
.query_row(
- "SELECT state FROM runs WHERE sha = ?1",
+ "SELECT outcome FROM runs WHERE sha = ?1",
rusqlite::params![&sha],
|row| row.get(0),
)
.expect("should have a run");
assert_eq!(
- state, "succeeded",
+ outcome.as_deref(),
+ Some("succeeded"),
"run should be succeeded after fake quire-ci exits 0"
);
- // No queued or active rows left behind.
+ // No unresolved rows left behind.
let count: i64 = conn
.query_row(
- "SELECT COUNT(*) FROM runs WHERE state IN ('queued', 'active')",
+ "SELECT COUNT(*) FROM runs WHERE outcome IS NULL",
[],
|row| row.get(0),
)
@@ -566,16 +567,19 @@ exit 0
);
let conn = crate::db::open(&quire.db_path()).expect("db");
- let state: String = conn
+ let outcome: Option<String> = conn
.query_row(
- "SELECT state FROM runs WHERE sha = ?1",
+ "SELECT outcome FROM runs WHERE sha = ?1",
rusqlite::params![&sha],
|row| row.get(0),
)
.expect("should have a run");
- assert_eq!(
- state, "failed",
- "run should be failed after quire-ci exits 1"
+ assert!(
+ outcome
+ .as_deref()
+ .map(|o| o.starts_with("failed"))
+ .unwrap_or(false),
+ "run should be failed after quire-ci exits 1, got: {outcome:?}"
);
}
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 90e3caf..929fa5a 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -27,44 +27,6 @@ pub enum Executor {
Process,
}
-/// The state of a CI run.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum RunState {
- Queued,
- Active,
- Succeeded,
- Failed,
- Canceled,
-}
-
-impl RunState {
- pub fn as_str(&self) -> &'static str {
- match self {
- RunState::Queued => "queued",
- RunState::Active => "active",
- RunState::Succeeded => "succeeded",
- RunState::Failed => "failed",
- RunState::Canceled => "canceled",
- }
- }
-}
-
-impl std::str::FromStr for RunState {
- type Err = ();
-
- fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
- match s {
- "queued" => Some(RunState::Queued),
- "active" => Some(RunState::Active),
- "succeeded" => Some(RunState::Succeeded),
- "failed" => Some(RunState::Failed),
- "canceled" => Some(RunState::Canceled),
- _ => None,
- }
- .ok_or(())
- }
-}
-
/// Access to CI runs for a single repo.
///
/// Owns the database path, repo name, and base directory for run
@@ -86,9 +48,9 @@ impl Runs {
}
}
- /// Create a new run record in the `queued` state.
+ /// Create a new run record in the queued state.
///
- /// Before inserting, cancels any existing `queued` or `active`
+ /// Before inserting, cancels any existing queued or active
/// run for the same `(repo, ref)`.
///
/// Inserts a row into `runs` and creates the run directory for
@@ -115,8 +77,8 @@ impl Runs {
self.cancel_existing(&db, &meta.r#ref)?;
db.execute(
- "INSERT INTO runs (id, repo, ref_name, sha, pushed_at_ms, state, queued_at_ms, run_token)
- VALUES (?1, ?2, ?3, ?4, ?5, 'queued', ?6, ?7)",
+ "INSERT INTO runs (id, repo, ref_name, sha, pushed_at_ms, created_at, run_token)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![
&id,
&self.repo,
@@ -135,12 +97,13 @@ impl Runs {
Ok(Run {
db_path: self.db_path.clone(),
id,
- state: RunState::Queued,
+ dispatched: false,
+ resolved: false,
base_dir: self.base_dir.clone(),
})
}
- /// Cancel any existing `queued` or `active` run for
+ /// Cancel any existing queued or active run for
/// `(repo, ref)`. Different refs are unaffected.
fn cancel_existing(&self, db: &rusqlite::Connection, ref_name: &str) -> Result<()> {
let now = Timestamp::now().as_millisecond();
@@ -148,22 +111,30 @@ impl Runs {
let active_ids: Vec<String> = db
.prepare(
"SELECT id FROM runs
- WHERE repo = ?1 AND ref_name = ?2 AND state = 'active'",
+ WHERE repo = ?1 AND ref_name = ?2
+ AND dispatched_at IS NOT NULL AND resolved_at IS NULL",
)?
.query_map(rusqlite::params![&self.repo, ref_name], |row| row.get(0))?
.collect::<std::result::Result<_, _>>()?;
for run_id in &active_ids {
db.execute(
- "UPDATE runs SET state = 'canceled', finished_at_ms = ?1 WHERE id = ?2",
- rusqlite::params![now, run_id],
+ "UPDATE runs SET \
+ dispatched_at = COALESCE(dispatched_at, ?1), \
+ resolved_at = COALESCE(resolved_at, ?2), \
+ outcome = COALESCE(outcome, 'superseded') \
+ WHERE id = ?3",
+ rusqlite::params![now, now, run_id],
)?;
tracing::info!(run_id = %run_id, "canceled active run");
}
let queued_count = db.execute(
- "UPDATE runs SET state = 'canceled', finished_at_ms = ?1
- WHERE repo = ?2 AND ref_name = ?3 AND state = 'queued'",
+ "UPDATE runs SET \
+ resolved_at = COALESCE(resolved_at, ?1), \
+ outcome = COALESCE(outcome, 'superseded') \
+ WHERE repo = ?2 AND ref_name = ?3
+ AND dispatched_at IS NULL AND resolved_at IS NULL",
rusqlite::params![now, &self.repo, ref_name],
)?;
if queued_count > 0 {
@@ -174,17 +145,19 @@ impl Runs {
}
}
-/// Move every `queued` or `active` run to `failed` with
-/// `failure_kind = 'orphaned'`. Called once at server startup to clean
-/// up runs left behind by a prior instance. Operates across all repos —
-/// orphans aren't a per-repo concern.
+/// Move every queued or active run to `failed-orphaned`. Called once at
+/// server startup to clean up runs left behind by a prior instance.
+/// Operates across all repos — orphans aren't a per-repo concern.
pub fn reconcile_orphans(db_path: &Path) -> Result<()> {
let now = Timestamp::now().as_millisecond();
let db = crate::db::open(db_path)?;
let count = db.execute(
- "UPDATE runs SET state = 'failed', finished_at_ms = ?1, failure_kind = 'orphaned'
- WHERE state IN ('queued', 'active')",
- rusqlite::params![now],
+ "UPDATE runs SET \
+ dispatched_at = COALESCE(dispatched_at, ?1), \
+ resolved_at = COALESCE(resolved_at, ?2), \
+ outcome = COALESCE(outcome, 'failed-orphaned') \
+ WHERE resolved_at IS NULL",
+ rusqlite::params![now, now],
)?;
if count > 0 {
tracing::warn!(count, "reconciled orphaned runs");
@@ -194,13 +167,16 @@ pub fn reconcile_orphans(db_path: &Path) -> Result<()> {
/// A CI run backed by a SQLite row.
///
-/// Owns the path to the database and the run's in-memory state cache.
+/// Owns the path to the database and the run's in-memory lifecycle flags.
/// Reads and writes go through SQL. The run directory on disk holds
/// the workspace and per-job log files.
pub struct Run {
db_path: PathBuf,
id: String,
- state: RunState,
+ /// Whether `dispatched_at` has been set (run is active or resolved).
+ dispatched: bool,
+ /// Whether `resolved_at` has been set (run is terminal).
+ resolved: bool,
base_dir: PathBuf,
}
@@ -215,29 +191,19 @@ impl Run {
&self.id
}
- /// The run's current state.
- pub fn state(&self) -> RunState {
- self.state
- }
-
/// Open an existing run from the database by ID.
pub fn open(db_path: PathBuf, id: String, base_dir: PathBuf) -> Result<Self> {
let db = crate::db::open(&db_path)?;
- let state_str: String = db.query_row(
- "SELECT state FROM runs WHERE id = ?1",
+ let (dispatched_at, resolved_at): (Option<i64>, Option<i64>) = db.query_row(
+ "SELECT dispatched_at, resolved_at FROM runs WHERE id = ?1",
rusqlite::params![&id],
- |row| row.get(0),
+ |row| Ok((row.get(0)?, row.get(1)?)),
)?;
- let state: RunState = state_str.parse().map_err(|_| {
- std::io::Error::new(
- std::io::ErrorKind::InvalidData,
- format!("invalid state in db: {state_str}"),
- )
- })?;
Ok(Self {
db_path,
id,
- state,
+ dispatched: dispatched_at.is_some(),
+ resolved: resolved_at.is_some(),
base_dir,
})
}
@@ -252,7 +218,7 @@ impl Run {
/// * `jobs/<job>/sh-<n>.log` — per-sh CRI logs, written by quire-ci
/// via `--out-dir`.
///
- /// Run finishes `Succeeded` on exit 0, `Failed` otherwise. The DB
+ /// Run finishes `succeeded` on exit 0, `failed-*` otherwise. The DB
/// rows are written even on failure so the web UI can render
/// partial progress.
pub fn execute(
@@ -264,15 +230,15 @@ impl Run {
session: Option<&ApiSession>,
) -> Result<()> {
// For API runs the GET /api/run/bootstrap endpoint owns the
- // queued → active transition (it sets started_at_ms when quire-ci
- // fetches the payload). Calling transition() here would set state =
- // 'active' in the DB before quire-ci connects, causing the endpoint
- // to return 410 Gone. Update local state only so the later
- // transition(Succeeded/Failed) call passes the state-machine check.
+ // dispatch (it sets dispatched_at when quire-ci fetches the payload).
+ // Calling dispatch() here would set dispatched_at in the DB before
+ // quire-ci connects, causing the endpoint to return 410 Gone.
+ // Update local flag only so the later resolve() call skips the
+ // already-dispatched guard.
if session.is_some() {
- self.state = RunState::Active;
+ self.dispatched = true;
} else {
- self.transition(RunState::Active, None)?;
+ self.dispatch()?;
}
let run_dir = self.path();
@@ -339,7 +305,7 @@ impl Run {
};
if !status.success() {
- self.transition(RunState::Failed, Some("process-crashed"))?;
+ self.resolve("failed-internal")?;
return Err(Error::ProcessFailed {
exit: status.code(),
});
@@ -350,13 +316,13 @@ impl Run {
// treat that as a crash too.
match run_outcome {
Some(quire_core::ci::event::RunOutcome::Succeeded) => {
- self.transition(RunState::Succeeded, None)?;
+ self.resolve("succeeded")?;
}
Some(quire_core::ci::event::RunOutcome::PipelineFailure) => {
- self.transition(RunState::Failed, Some("pipeline-failure"))?;
+ self.resolve("failed-pipeline")?;
}
None => {
- self.transition(RunState::Failed, Some("process-crashed"))?;
+ self.resolve("failed-internal")?;
return Err(Error::ProcessFailed {
exit: status.code(),
});
@@ -466,75 +432,38 @@ impl Run {
Ok(())
}
- /// Transition the run from its current state to a new state.
- ///
- /// Allowed edges (see `docs/CI-STATE.md`):
- ///
- /// * `Queued → Active`
- /// * `Queued → Succeeded`
- /// * `Queued → Canceled`
- /// * `Active → Succeeded`
- /// * `Active → Failed`
- /// * `Active → Canceled`
- ///
- /// `failure_kind` is recorded only when transitioning to
- /// `Failed`; it is ignored for other targets. Pass a short tag
- /// (`"quire-ci-exit"`) so the UI can distinguish job-pipeline
- /// failures from `reconcile_orphans`'s `"orphaned"`. Each
- /// timestamp and `failure_kind` is set at most once (via
- /// `COALESCE`).
- pub fn transition(&mut self, to: RunState, failure_kind: Option<&str>) -> Result<()> {
- use RunState::*;
- let allowed = matches!(
- (self.state, to),
- (Queued, Active)
- | (Queued, Succeeded)
- | (Queued, Canceled)
- | (Active, Succeeded)
- | (Active, Failed)
- | (Active, Canceled)
- );
- if !allowed {
- return Err(Error::InvalidTransition {
- from: self.state,
- to,
- });
+ /// Mark the run as dispatched (queued → active). Sets `dispatched_at`.
+ pub fn dispatch(&mut self) -> Result<()> {
+ if self.dispatched || self.resolved {
+ return Err(Error::AlreadyDispatched);
}
-
let now = Timestamp::now().as_millisecond();
let db = crate::db::open(&self.db_path)?;
+ db.execute(
+ "UPDATE runs SET dispatched_at = COALESCE(dispatched_at, ?1) WHERE id = ?2",
+ rusqlite::params![now, &self.id],
+ )?;
+ self.dispatched = true;
+ Ok(())
+ }
- match to {
- Active => {
- db.execute(
- "UPDATE runs SET state = 'active', started_at_ms = COALESCE(started_at_ms, ?1)
- WHERE id = ?2",
- rusqlite::params![now, &self.id],
- )?;
- }
- Succeeded | Canceled => {
- db.execute(
- "UPDATE runs SET state = ?1, \
- started_at_ms = COALESCE(started_at_ms, ?2), \
- finished_at_ms = COALESCE(finished_at_ms, ?3) \
- WHERE id = ?4",
- rusqlite::params![to.as_str(), now, now, &self.id],
- )?;
- }
- Failed => {
- db.execute(
- "UPDATE runs SET state = 'failed', \
- started_at_ms = COALESCE(started_at_ms, ?1), \
- finished_at_ms = COALESCE(finished_at_ms, ?2), \
- failure_kind = COALESCE(failure_kind, ?3) \
- WHERE id = ?4",
- rusqlite::params![now, now, failure_kind, &self.id],
- )?;
- }
- Queued => unreachable!("transition to Queued is not valid"),
+ /// Resolve the run with an outcome. Sets `resolved_at` and `outcome`.
+ pub fn resolve(&mut self, outcome: &str) -> Result<()> {
+ if self.resolved {
+ return Err(Error::AlreadyResolved);
}
-
- self.state = to;
+ let now = Timestamp::now().as_millisecond();
+ let db = crate::db::open(&self.db_path)?;
+ db.execute(
+ "UPDATE runs SET \
+ dispatched_at = COALESCE(dispatched_at, ?1), \
+ resolved_at = COALESCE(resolved_at, ?2), \
+ outcome = COALESCE(outcome, ?3) \
+ WHERE id = ?4",
+ rusqlite::params![now, now, outcome, &self.id],
+ )?;
+ self.dispatched = true;
+ self.resolved = true;
Ok(())
}
@@ -559,27 +488,38 @@ impl Run {
})
}
- /// Read the `started_at` timestamp for this run, if set.
- pub fn read_started_at(&self) -> Result<Option<Timestamp>> {
+ /// Read the `dispatched_at` timestamp for this run, if set.
+ pub fn read_dispatched_at(&self) -> Result<Option<Timestamp>> {
let db = crate::db::open(&self.db_path)?;
let ms: Option<i64> = db.query_row(
- "SELECT started_at_ms FROM runs WHERE id = ?1",
+ "SELECT dispatched_at FROM runs WHERE id = ?1",
rusqlite::params![&self.id],
|row| row.get(0),
)?;
Ok(ms.map(|m| Timestamp::from_millisecond(m).expect("valid timestamp")))
}
- /// Read the `finished_at` timestamp for this run, if set.
- pub fn read_finished_at(&self) -> Result<Option<Timestamp>> {
+ /// Read the `resolved_at` timestamp for this run, if set.
+ pub fn read_resolved_at(&self) -> Result<Option<Timestamp>> {
let db = crate::db::open(&self.db_path)?;
let ms: Option<i64> = db.query_row(
- "SELECT finished_at_ms FROM runs WHERE id = ?1",
+ "SELECT resolved_at FROM runs WHERE id = ?1",
rusqlite::params![&self.id],
|row| row.get(0),
)?;
Ok(ms.map(|m| Timestamp::from_millisecond(m).expect("valid timestamp")))
}
+
+ /// Read the `outcome` string for this run, if set.
+ pub fn read_outcome(&self) -> Result<Option<String>> {
+ let db = crate::db::open(&self.db_path)?;
+ let outcome: Option<String> = db.query_row(
+ "SELECT outcome FROM runs WHERE id = ?1",
+ rusqlite::params![&self.id],
+ |row| row.get(0),
+ )?;
+ Ok(outcome)
+ }
}
/// Take the final path component of a runs base (`runs/<repo>/`) and
@@ -756,20 +696,6 @@ mod tests {
);
}
- #[test]
- fn run_state_round_trips() {
- for state in [
- RunState::Queued,
- RunState::Active,
- RunState::Succeeded,
- RunState::Failed,
- RunState::Canceled,
- ] {
- assert!(state.as_str().parse::<RunState>().is_ok());
- }
- assert!("unknown".parse::<RunState>().is_err());
- }
-
#[test]
fn create_generates_uuidv7_id() {
let (_dir, quire) = tmp_quire();
@@ -832,7 +758,9 @@ mod tests {
.create(&test_meta(), Some(&test_session()))
.expect("create");
- assert_eq!(run.state(), RunState::Queued);
+ // New run is not dispatched or resolved.
+ assert!(!run.dispatched);
+ assert!(!run.resolved);
// Verify workspace directory was created.
let workspace = run.path().join("workspace");
@@ -842,15 +770,15 @@ mod tests {
let meta = run.read_meta().expect("read meta");
assert_eq!(meta.sha, "abc123");
- // No started_at yet.
- let started = run.read_started_at().expect("read started_at");
- assert!(started.is_none());
- let finished = run.read_finished_at().expect("read finished_at");
- assert!(finished.is_none());
+ // No dispatched_at or resolved_at yet.
+ let dispatched = run.read_dispatched_at().expect("read dispatched_at");
+ assert!(dispatched.is_none());
+ let resolved = run.read_resolved_at().expect("read resolved_at");
+ assert!(resolved.is_none());
}
#[test]
- fn transition_updates_state_in_db() {
+ fn dispatch_updates_db() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
let mut run = runs
@@ -858,167 +786,142 @@ mod tests {
.expect("create");
let id = run.id().to_string();
- run.transition(RunState::Active, None).expect("transition");
- assert_eq!(run.state(), RunState::Active);
+ run.dispatch().expect("dispatch");
+ assert!(run.dispatched);
+ assert!(!run.resolved);
- // Verify started_at was stamped.
- let started = run.read_started_at().expect("read started_at");
- assert!(started.is_some(), "started_at should be stamped");
+ // Verify dispatched_at was stamped.
+ let dispatched = run.read_dispatched_at().expect("read dispatched_at");
+ assert!(dispatched.is_some(), "dispatched_at should be stamped");
// Re-open the run and verify state persists.
let reopened =
Run::open(quire.db_path(), id.clone(), runs.base_dir.clone()).expect("reopen");
- assert_eq!(reopened.state(), RunState::Active);
+ assert!(reopened.dispatched);
+ assert!(!reopened.resolved);
assert_eq!(reopened.id(), id);
}
#[test]
- fn transition_stamps_started_at_on_active() {
+ fn dispatch_stamps_dispatched_at() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
let mut run = runs
.create(&test_meta(), Some(&test_session()))
.expect("create");
- run.transition(RunState::Active, None).expect("to active");
- let started = run.read_started_at().expect("read started_at");
- assert!(started.is_some(), "started_at should be stamped");
- assert!(run.read_finished_at().expect("read").is_none());
+ run.dispatch().expect("dispatch");
+ let dispatched = run.read_dispatched_at().expect("read dispatched_at");
+ assert!(dispatched.is_some(), "dispatched_at should be stamped");
+ assert!(run.read_resolved_at().expect("read").is_none());
}
#[test]
- fn transition_stamps_finished_at_on_succeeded_and_failed() {
+ fn resolve_stamps_resolved_at_on_succeeded_and_failed() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
let mut completed = runs
.create(&test_meta(), Some(&test_session()))
.expect("create");
- completed
- .transition(RunState::Active, None)
- .expect("to active");
- completed
- .transition(RunState::Succeeded, None)
- .expect("to succeed");
- assert!(completed.read_finished_at().expect("read").is_some());
+ completed.dispatch().expect("dispatch");
+ completed.resolve("succeeded").expect("resolve succeeded");
+ assert!(completed.read_resolved_at().expect("read").is_some());
+ assert_eq!(
+ completed.read_outcome().expect("read outcome").as_deref(),
+ Some("succeeded")
+ );
let mut failed = runs
.create(&test_meta(), Some(&test_session()))
.expect("create");
- failed
- .transition(RunState::Active, None)
- .expect("to active");
- failed
- .transition(RunState::Failed, Some("job-error"))
- .expect("to failed");
- assert!(failed.read_finished_at().expect("read").is_some());
+ failed.dispatch().expect("dispatch");
+ failed.resolve("failed-pipeline").expect("resolve failed");
+ assert!(failed.read_resolved_at().expect("read").is_some());
+ assert_eq!(
+ failed.read_outcome().expect("read outcome").as_deref(),
+ Some("failed-pipeline")
+ );
}
#[test]
- fn transition_records_failure_kind_on_failed() {
+ fn resolve_records_outcome() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
let mut run = runs
.create(&test_meta(), Some(&test_session()))
.expect("create");
- let id = run.id().to_string();
- run.transition(RunState::Active, None).expect("to active");
- run.transition(RunState::Failed, Some("job-error"))
- .expect("to failed");
+ run.dispatch().expect("dispatch");
+ run.resolve("failed-internal").expect("resolve");
- let db = crate::db::open(&quire.db_path()).expect("open db");
- let kind: Option<String> = db
- .query_row(
- "SELECT failure_kind FROM runs WHERE id = ?1",
- rusqlite::params![&id],
- |row| row.get(0),
- )
- .expect("query");
- assert_eq!(kind.as_deref(), Some("job-error"));
+ let outcome = run.read_outcome().expect("read outcome");
+ assert_eq!(outcome.as_deref(), Some("failed-internal"));
}
#[test]
- fn transition_skips_failure_kind_when_none() {
+ fn resolve_rejects_double_resolve() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
- let mut run = runs
+
+ // Already resolved -> error.
+ let mut completed = runs
.create(&test_meta(), Some(&test_session()))
.expect("create");
- let id = run.id().to_string();
-
- run.transition(RunState::Active, None).expect("to active");
- run.transition(RunState::Failed, None).expect("to failed");
-
- let db = crate::db::open(&quire.db_path()).expect("open db");
- let kind: Option<String> = db
- .query_row(
- "SELECT failure_kind FROM runs WHERE id = ?1",
- rusqlite::params![&id],
- |row| row.get(0),
- )
- .expect("query");
- assert!(kind.is_none());
+ completed.dispatch().expect("dispatch");
+ completed.resolve("succeeded").expect("to succeed");
+ assert!(completed.resolve("succeeded").is_err());
+ assert!(completed.resolve("failed-pipeline").is_err());
}
#[test]
- fn transition_rejects_invalid_transitions() {
+ fn dispatch_rejects_double_dispatch() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
- // Queued -> Failed is not allowed (must go via Active).
let mut run = runs
.create(&test_meta(), Some(&test_session()))
.expect("create");
- assert!(run.transition(RunState::Failed, None).is_err());
-
- // Terminal -> anything is not allowed.
- let mut completed = runs
- .create(&test_meta(), Some(&test_session()))
- .expect("create");
- completed
- .transition(RunState::Active, None)
- .expect("to active");
- completed
- .transition(RunState::Succeeded, None)
- .expect("to succeed");
- assert!(completed.transition(RunState::Active, None).is_err());
- assert!(completed.transition(RunState::Failed, None).is_err());
+ run.dispatch().expect("dispatch");
+ assert!(run.dispatch().is_err());
}
#[test]
- fn transition_preserves_started_at_through_completion() {
+ fn resolve_preserves_dispatched_at_through_completion() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
let mut run = runs
.create(&test_meta(), Some(&test_session()))
.expect("create");
- run.transition(RunState::Active, None).expect("to active");
- let started = run.read_started_at().expect("read started_at");
+ run.dispatch().expect("dispatch");
+ let dispatched = run.read_dispatched_at().expect("read dispatched_at");
- run.transition(RunState::Succeeded, None)
- .expect("to succeed");
+ run.resolve("succeeded").expect("resolve");
assert_eq!(
- run.read_started_at().expect("read"),
- started,
- "started_at preserved"
+ run.read_dispatched_at().expect("read"),
+ dispatched,
+ "dispatched_at preserved"
);
}
#[test]
- fn transition_full_lifecycle() {
+ fn full_lifecycle_dispatch_then_resolve() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
let mut run = runs
.create(&test_meta(), Some(&test_session()))
.expect("create");
- run.transition(RunState::Active, None).expect("to active");
- run.transition(RunState::Succeeded, None)
- .expect("to succeed");
+ run.dispatch().expect("dispatch");
+ run.resolve("succeeded").expect("resolve");
- assert_eq!(run.state(), RunState::Succeeded);
+ assert!(run.dispatched);
+ assert!(run.resolved);
+ assert_eq!(
+ run.read_outcome().expect("outcome").as_deref(),
+ Some("succeeded")
+ );
}
#[test]
@@ -1033,7 +936,11 @@ mod tests {
reconcile_orphans(&quire.db_path()).expect("reconcile");
let reopened = Run::open(quire.db_path(), id, runs.base_dir.clone()).expect("reopen");
- assert_eq!(reopened.state(), RunState::Failed);
+ assert!(reopened.resolved, "orphaned run should be resolved");
+ assert_eq!(
+ reopened.read_outcome().expect("outcome").as_deref(),
+ Some("failed-orphaned")
+ );
}
#[test]
@@ -1043,13 +950,17 @@ mod tests {
let mut run = runs
.create(&test_meta(), Some(&test_session()))
.expect("create");
- run.transition(RunState::Active, None).expect("to active");
+ run.dispatch().expect("dispatch");
let id = run.id().to_string();
reconcile_orphans(&quire.db_path()).expect("reconcile");
let reopened = Run::open(quire.db_path(), id, runs.base_dir.clone()).expect("reopen");
- assert_eq!(reopened.state(), RunState::Failed);
+ assert!(reopened.resolved, "orphaned active run should be resolved");
+ assert_eq!(
+ reopened.read_outcome().expect("outcome").as_deref(),
+ Some("failed-orphaned")
+ );
}
#[test]
@@ -1059,15 +970,17 @@ mod tests {
let mut run = runs
.create(&test_meta(), Some(&test_session()))
.expect("create");
- run.transition(RunState::Active, None).expect("to active");
- run.transition(RunState::Succeeded, None)
- .expect("to succeed");
+ run.dispatch().expect("dispatch");
+ run.resolve("succeeded").expect("resolve");
let id = run.id().to_string();
reconcile_orphans(&quire.db_path()).expect("reconcile");
let reopened = Run::open(quire.db_path(), id, runs.base_dir.clone()).expect("reopen");
- assert_eq!(reopened.state(), RunState::Succeeded);
+ assert_eq!(
+ reopened.read_outcome().expect("outcome").as_deref(),
+ Some("succeeded")
+ );
}
#[test]
@@ -1227,7 +1140,7 @@ mod tests {
.create(&test_meta(), Some(&test_session()))
.expect("create run1");
let run1_id = run1.id().to_string();
- assert_eq!(run1.state(), RunState::Queued);
+ assert!(!run1.dispatched && !run1.resolved);
// Create second run for same (repo, ref) — should cancel the first.
let meta2 = RunMeta {
@@ -1238,14 +1151,18 @@ mod tests {
let run2 = runs
.create(&meta2, Some(&test_session()))
.expect("create run2");
- assert_eq!(run2.state(), RunState::Queued);
+ assert!(!run2.dispatched && !run2.resolved);
- // First run should now be canceled.
+ // First run should now be superseded (resolved).
let reopened = Run::open(quire.db_path(), run1_id, runs.base_dir.clone()).expect("reopen");
- assert_eq!(reopened.state(), RunState::Canceled);
+ assert!(reopened.resolved, "canceled run should be resolved");
+ assert_eq!(
+ reopened.read_outcome().expect("outcome").as_deref(),
+ Some("superseded")
+ );
assert!(
- reopened.read_finished_at().expect("read").is_some(),
- "canceled run should have finished_at"
+ reopened.read_resolved_at().expect("read").is_some(),
+ "canceled run should have resolved_at"
);
}
@@ -1254,12 +1171,12 @@ mod tests {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
- // Create and activate first run.
+ // Create and dispatch first run.
let mut run1 = runs
.create(&test_meta(), Some(&test_session()))
.expect("create run1");
let run1_id = run1.id().to_string();
- run1.transition(RunState::Active, None).expect("to active");
+ run1.dispatch().expect("dispatch");
// Create second run for same (repo, ref).
let meta2 = RunMeta {
@@ -1270,14 +1187,18 @@ mod tests {
let run2 = runs
.create(&meta2, Some(&test_session()))
.expect("create run2");
- assert_eq!(run2.state(), RunState::Queued);
+ assert!(!run2.dispatched && !run2.resolved);
- // First run should be canceled.
+ // First run should be superseded.
let reopened = Run::open(quire.db_path(), run1_id, runs.base_dir.clone()).expect("reopen");
- assert_eq!(reopened.state(), RunState::Canceled);
+ assert!(reopened.resolved, "canceled run should be resolved");
+ assert_eq!(
+ reopened.read_outcome().expect("outcome").as_deref(),
+ Some("superseded")
+ );
assert!(
- reopened.read_finished_at().expect("read").is_some(),
- "canceled run should have finished_at"
+ reopened.read_resolved_at().expect("read").is_some(),
+ "canceled run should have resolved_at"
);
}
@@ -1302,9 +1223,9 @@ mod tests {
.create(&meta2, Some(&test_session()))
.expect("create run2");
- // First run should still be queued.
+ // First run should still be queued (not dispatched, not resolved).
let reopened = Run::open(quire.db_path(), run1_id, runs.base_dir.clone()).expect("reopen");
- assert_eq!(reopened.state(), RunState::Queued);
+ assert!(!reopened.dispatched && !reopened.resolved);
}
#[test]
@@ -1317,9 +1238,8 @@ mod tests {
.create(&test_meta(), Some(&test_session()))
.expect("create run1");
let run1_id = run1.id().to_string();
- run1.transition(RunState::Active, None).expect("to active");
- run1.transition(RunState::Succeeded, None)
- .expect("to succeed");
+ run1.dispatch().expect("dispatch");
+ run1.resolve("succeeded").expect("resolve");
// Create second run for same (repo, ref).
let meta2 = RunMeta {
@@ -1333,50 +1253,30 @@ mod tests {
// First run should still be succeeded.
let reopened = Run::open(quire.db_path(), run1_id, runs.base_dir.clone()).expect("reopen");
- assert_eq!(reopened.state(), RunState::Succeeded);
- }
-
- #[test]
- fn transition_allows_queued_to_canceled() {
- let (_dir, quire) = tmp_quire();
- let runs = test_runs(&quire);
- let mut run = runs
- .create(&test_meta(), Some(&test_session()))
- .expect("create");
- run.transition(RunState::Canceled, None).expect("to cancel");
- assert_eq!(run.state(), RunState::Canceled);
- }
-
- #[test]
- fn transition_allows_active_to_canceled() {
- let (_dir, quire) = tmp_quire();
- let runs = test_runs(&quire);
- let mut run = runs
- .create(&test_meta(), Some(&test_session()))
- .expect("create");
- run.transition(RunState::Active, None).expect("to active");
- run.transition(RunState::Canceled, None).expect("to cancel");
- assert_eq!(run.state(), RunState::Canceled);
+ assert_eq!(
+ reopened.read_outcome().expect("outcome").as_deref(),
+ Some("succeeded")
+ );
}
#[test]
- fn cancel_sets_finished_at() {
+ fn resolve_sets_resolved_at() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
let mut run = runs
.create(&test_meta(), Some(&test_session()))
.expect("create");
- run.transition(RunState::Active, None).expect("to active");
+ run.dispatch().expect("dispatch");
assert!(
- run.read_finished_at().expect("read").is_none(),
- "should not have finished_at before cancel"
+ run.read_resolved_at().expect("read").is_none(),
+ "should not have resolved_at before resolve"
);
- run.transition(RunState::Canceled, None).expect("to cancel");
+ run.resolve("superseded").expect("resolve");
assert!(
- run.read_finished_at().expect("read").is_some(),
- "canceled run should have finished_at"
+ run.read_resolved_at().expect("read").is_some(),
+ "resolved run should have resolved_at"
);
}
}
diff --git a/quire-server/src/db.rs b/quire-server/src/db.rs
index b7a047d..45d23ca 100644
--- a/quire-server/src/db.rs
+++ b/quire-server/src/db.rs
@@ -21,6 +21,7 @@ static MIGRATIONS: std::sync::LazyLock<Migrations<'static>> = std::sync::LazyLoc
M::up(include_str!("../migrations/0007_schema_cleanup.sql")),
M::up(include_str!("../migrations/0008_rename_sh.sql")),
M::up(include_str!("../migrations/0009_rename_ci_vocab.sql")),
+ M::up(include_str!("../migrations/0010_outcome_schema.sql")),
])
});
diff --git a/quire-server/src/quire/web/api.rs b/quire-server/src/quire/web/api.rs
index ac8a879..b84318a 100644
--- a/quire-server/src/quire/web/api.rs
+++ b/quire-server/src/quire/web/api.rs
@@ -155,20 +155,20 @@ async fn get_bootstrap(
pushed_at_ms: i64,
git_dir: Option<String>,
traceparent: Option<String>,
- state: String,
+ dispatched_at: Option<i64>,
repo: String,
}
let row: RunRow = db
.prepare(
- "SELECT sha, ref_name, pushed_at_ms, git_dir, traceparent, state, repo
+ "SELECT sha, ref_name, pushed_at_ms, git_dir, traceparent, dispatched_at, repo
FROM runs WHERE id = ?1",
)?
.query_and_then(rusqlite::params![run_id], serde_rusqlite::from_row)?
.next()
.ok_or(rusqlite::Error::QueryReturnedNoRows)??;
- if row.state != "queued" {
+ if row.dispatched_at.is_some() {
return Err(ApiError::Gone);
}
@@ -181,10 +181,10 @@ async fn get_bootstrap(
.expect("db stores valid timestamps"),
};
- let started_at_ms = jiff::Timestamp::now().as_millisecond();
+ let dispatched_at_ms = jiff::Timestamp::now().as_millisecond();
db.execute(
- "UPDATE runs SET state = 'active', started_at_ms = ?1 WHERE id = ?2",
- rusqlite::params![started_at_ms, run_id],
+ "UPDATE runs SET dispatched_at = ?1 WHERE id = ?2",
+ rusqlite::params![dispatched_at_ms, run_id],
)?;
Ok(Bootstrap {
diff --git a/quire-server/src/quire/web/db.rs b/quire-server/src/quire/web/db.rs
index a395469..c59e154 100644
--- a/quire-server/src/quire/web/db.rs
+++ b/quire-server/src/quire/web/db.rs
@@ -5,12 +5,23 @@ use crate::{Quire, Result};
/// Raw run row from the database.
pub struct RunRow {
pub id: String,
- pub state: String,
+ pub outcome: Option<String>,
pub sha: String,
pub ref_name: String,
- pub queued_at_ms: i64,
- pub started_at_ms: Option<i64>,
- pub finished_at_ms: Option<i64>,
+ pub created_at: i64,
+ pub dispatched_at: Option<i64>,
+ pub resolved_at: Option<i64>,
+}
+
+impl RunRow {
+ pub fn derived_state(&self) -> &str {
+ match &self.outcome {
+ Some(o) if o.starts_with("failed") => "failed",
+ Some(o) => o.as_str(),
+ None if self.dispatched_at.is_some() => "active",
+ None => "queued",
+ }
+ }
}
/// Raw job row from the database.
@@ -37,9 +48,9 @@ pub fn load_runs(quire: &Quire, repo: &str) -> Result<Vec<RunRow>> {
.lock()
.map_err(|_| crate::error::Error::Io(std::io::Error::other("db mutex poisoned")))?;
let mut stmt = db.prepare(
- "SELECT id, state, sha, ref_name, queued_at_ms, started_at_ms, finished_at_ms
+ "SELECT id, outcome, sha, ref_name, created_at, dispatched_at, resolved_at
FROM runs WHERE repo = ?1
- ORDER BY queued_at_ms DESC
+ ORDER BY created_at DESC
LIMIT 50",
)?;
@@ -47,12 +58,12 @@ pub fn load_runs(quire: &Quire, repo: &str) -> Result<Vec<RunRow>> {
.query_map(rusqlite::params![repo], |row| {
Ok(RunRow {
id: row.get(0)?,
- state: row.get(1)?,
+ outcome: row.get(1)?,
sha: row.get(2)?,
ref_name: row.get(3)?,
- queued_at_ms: row.get(4)?,
- started_at_ms: row.get(5)?,
- finished_at_ms: row.get(6)?,
+ created_at: row.get(4)?,
+ dispatched_at: row.get(5)?,
+ resolved_at: row.get(6)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
@@ -74,18 +85,18 @@ pub fn load_run_detail(quire: &Quire, repo: &str, run_id: &str) -> Result<RunDet
.map_err(|_| crate::error::Error::Io(std::io::Error::other("db mutex poisoned")))?;
let run = db.query_row(
- "SELECT id, state, sha, ref_name, queued_at_ms, started_at_ms, finished_at_ms
+ "SELECT id, outcome, sha, ref_name, created_at, dispatched_at, resolved_at
FROM runs WHERE id = ?1 AND repo = ?2",
rusqlite::params![run_id, repo],
|row| {
Ok(RunRow {
id: row.get(0)?,
- state: row.get(1)?,
+ outcome: row.get(1)?,
sha: row.get(2)?,
ref_name: row.get(3)?,
- queued_at_ms: row.get(4)?,
- started_at_ms: row.get(5)?,
- finished_at_ms: row.get(6)?,
+ created_at: row.get(4)?,
+ dispatched_at: row.get(5)?,
+ resolved_at: row.get(6)?,
})
},
)?;
diff --git a/quire-server/src/quire/web/handlers.rs b/quire-server/src/quire/web/handlers.rs
index 59e6066..adb401c 100644
--- a/quire-server/src/quire/web/handlers.rs
+++ b/quire-server/src/quire/web/handlers.rs
@@ -116,12 +116,12 @@ pub async fn run_list(State(quire): State<Quire>, AxumPath(repo): AxumPath<Strin
.into_iter()
.map(|r| RunListRow {
id: r.id,
- state: r.state,
+ outcome: r.outcome,
sha: r.sha,
ref_name: r.ref_name,
- queued_at_ms: r.queued_at_ms,
- started_at_ms: r.started_at_ms,
- finished_at_ms: r.finished_at_ms,
+ created_at: r.created_at,
+ dispatched_at: r.dispatched_at,
+ resolved_at: r.resolved_at,
})
.collect();
@@ -170,12 +170,12 @@ pub async fn run_detail(
};
let detail_run = DetailRun {
- state: detail.run.state,
+ outcome: detail.run.outcome,
sha: detail.run.sha,
ref_name: detail.run.ref_name,
- queued_at_ms: detail.run.queued_at_ms,
- started_at_ms: detail.run.started_at_ms,
- finished_at_ms: detail.run.finished_at_ms,
+ created_at: detail.run.created_at,
+ dispatched_at: detail.run.dispatched_at,
+ resolved_at: detail.run.resolved_at,
};
// Group sh events by job_id, preserving DB order so positional index
@@ -315,20 +315,20 @@ mod tests {
fn insert_run(
&self,
id: &str,
- state: &str,
+ outcome: Option<&str>,
sha: &str,
ref_name: &str,
- queued: i64,
- started: Option<i64>,
- finished: Option<i64>,
+ created: i64,
+ dispatched: Option<i64>,
+ resolved: Option<i64>,
) {
let pool = self.quire.db_pool();
let db = pool.lock().expect("lock");
db.execute(
- "INSERT INTO runs (id, repo, ref_name, sha, pushed_at_ms, state, failure_kind,
- queued_at_ms, started_at_ms, finished_at_ms)
- VALUES (?1, 'example.git', ?2, ?3, ?4, ?5, NULL, ?4, ?6, ?7)",
- rusqlite::params![id, ref_name, sha, queued, state, started, finished],
+ "INSERT INTO runs (id, repo, ref_name, sha, pushed_at_ms,
+ created_at, dispatched_at, resolved_at, outcome)
+ VALUES (?1, 'example.git', ?2, ?3, ?4, ?4, ?5, ?6, ?7)",
+ rusqlite::params![id, ref_name, sha, created, dispatched, resolved, outcome],
)
.expect("insert run");
}
@@ -393,7 +393,7 @@ mod tests {
let env = TestEnv::new();
env.insert_run(
UUID1,
- "succeeded",
+ Some("succeeded"),
SHA1,
"refs/heads/main",
1000,
@@ -426,7 +426,7 @@ mod tests {
let env = TestEnv::new();
env.insert_run(
UUID1,
- "succeeded",
+ Some("succeeded"),
SHA1,
"refs/heads/main",
1000,
diff --git a/quire-server/src/quire/web/templates.rs b/quire-server/src/quire/web/templates.rs
index fc0fc90..499667c 100644
--- a/quire-server/src/quire/web/templates.rs
+++ b/quire-server/src/quire/web/templates.rs
@@ -51,17 +51,26 @@ impl RunListTemplate {
pub struct RunListRow {
pub id: String,
- pub state: String,
+ pub outcome: Option<String>,
pub sha: String,
pub ref_name: String,
- pub queued_at_ms: i64,
- pub started_at_ms: Option<i64>,
- pub finished_at_ms: Option<i64>,
+ pub created_at: i64,
+ pub dispatched_at: Option<i64>,
+ pub resolved_at: Option<i64>,
}
impl RunListRow {
+ pub fn state(&self) -> &str {
+ match &self.outcome {
+ Some(o) if o.starts_with("failed") => "failed",
+ Some(o) => o.as_str(),
+ None if self.dispatched_at.is_some() => "active",
+ None => "queued",
+ }
+ }
+
pub fn state_class(&self) -> &'static str {
- format::state_class(&self.state)
+ format::state_class(self.state())
}
pub fn sha_short(&self) -> &str {
@@ -73,15 +82,15 @@ impl RunListRow {
}
pub fn queued_relative(&self) -> String {
- format::format_timestamp_relative(self.queued_at_ms)
+ format::format_timestamp_relative(self.created_at)
}
pub fn queued_iso(&self) -> String {
- format::format_timestamp_iso(self.queued_at_ms)
+ format::format_timestamp_iso(self.created_at)
}
pub fn duration_display(&self) -> String {
- format::format_duration(self.started_at_ms, self.finished_at_ms)
+ format::format_duration(self.dispatched_at, self.resolved_at)
}
}
@@ -104,17 +113,26 @@ impl RunDetailTemplate {
}
pub struct DetailRun {
- pub state: String,
+ pub outcome: Option<String>,
pub sha: String,
pub ref_name: String,
- pub queued_at_ms: i64,
- pub started_at_ms: Option<i64>,
- pub finished_at_ms: Option<i64>,
+ pub created_at: i64,
+ pub dispatched_at: Option<i64>,
+ pub resolved_at: Option<i64>,
}
impl DetailRun {
+ pub fn state(&self) -> &str {
+ match &self.outcome {
+ Some(o) if o.starts_with("failed") => "failed",
+ Some(o) => o.as_str(),
+ None if self.dispatched_at.is_some() => "active",
+ None => "queued",
+ }
+ }
+
pub fn state_class(&self) -> &'static str {
- format::state_class(&self.state)
+ format::state_class(self.state())
}
pub fn sha_short(&self) -> &str {
@@ -126,51 +144,55 @@ impl DetailRun {
}
pub fn queued_relative(&self) -> String {
- format::format_timestamp_relative(self.queued_at_ms)
+ format::format_timestamp_relative(self.created_at)
}
pub fn queued_iso(&self) -> String {
- format::format_timestamp_iso(self.queued_at_ms)
+ format::format_timestamp_iso(self.created_at)
}
pub fn started_display(&self) -> String {
- self.started_at_ms
+ self.dispatched_at
.map(format::format_timestamp_relative)
.unwrap_or_else(|| "—".to_string())
}
pub fn started_iso(&self) -> String {
- self.started_at_ms
+ self.dispatched_at
.map(format::format_timestamp_iso)
.unwrap_or_default()
}
pub fn has_started(&self) -> bool {
- self.started_at_ms.is_some()
+ self.dispatched_at.is_some()
}
pub fn finished_display(&self) -> String {
- self.finished_at_ms
+ self.resolved_at
.map(format::format_timestamp_relative)
.unwrap_or_else(|| "—".to_string())
}
pub fn finished_iso(&self) -> String {
- self.finished_at_ms
+ self.resolved_at
.map(format::format_timestamp_iso)
.unwrap_or_default()
}
pub fn has_finished(&self) -> bool {
- self.finished_at_ms.is_some()
+ self.resolved_at.is_some()
+ }
+
+ pub fn is_resolved(&self) -> bool {
+ self.outcome.is_some()
}
pub fn is_terminal(&self) -> bool {
- self.state == "succeeded" || self.state == "failed"
+ self.is_resolved()
}
pub fn duration_display(&self) -> String {
- format::format_duration(self.started_at_ms, self.finished_at_ms)
+ format::format_duration(self.dispatched_at, self.resolved_at)
}
}
diff --git a/quire-server/templates/ci/run_detail.html b/quire-server/templates/ci/run_detail.html
index 37db90a..e5f1d79 100644
--- a/quire-server/templates/ci/run_detail.html
+++ b/quire-server/templates/ci/run_detail.html
@@ -11,7 +11,7 @@
<div class="ci-meta-primary">
<span class="c-accent">{{ run.sha_short() }}</span>
· {{ run.branch_short() }}
- · <span class="ci-state-label {{ run.state_class() }}"><span class="ci-status-dot {{ run.state_class() }}"></span> {{ run.state }}</span>
+ · <span class="ci-state-label {{ run.state_class() }}"><span class="ci-status-dot {{ run.state_class() }}"></span> {{ run.state() }}</span>
</div>
<div class="ci-meta-secondary">
{% if run.is_terminal() %}