Document CI state machines and tag run failures by cause
Adds docs/CI-STATE.md with mermaid diagrams for the run and job state
machines, the QuireCi event-ingest flow, and a gaps section listing
states that the schema admits or CI.md commits to but no code path
produces yet (job skipped/aborted, run superseded, allow-failure,
queue/Notify wakeup).
Tightens Run::transition to match the documented edges: drops the
unused (Pending, Complete) arm and adds a failure_kind: Option<&str>
parameter so job-error, quire-ci-exit, and compile-error failures
are tagged in the runs.failure_kind column alongside the existing
orphaned value, giving the UI enough context to distinguish causes.
diff --git a/docs/CI-STATE.md b/docs/CI-STATE.md
new file mode 100644
index 0000000..a641ba7
--- /dev/null
+++ b/docs/CI-STATE.md
@@ -0,0 +1,240 @@
+# quire — CI state machines
+
+A reader's guide to the two state machines that govern a CI run, paired with what the code actually writes today vs. what the schema and `CI.md` describe. `CI.md` is the architectural design; this doc is the lifecycle inside that design.
+
+There are two machines:
+
+1. **Run state** — the row in `runs`. One per `(repo, ref, push)`.
+2. **Job state** — the row in `jobs`. One per job inside a run.
+
+A run owns its jobs; jobs FK on `(run_id, job_id)` and cascade delete.
+
+## Run state machine
+
+### Diagram
+
+```mermaid
+stateDiagram-v2
+ [*] --> pending : Runs::create
+
+ pending --> active : transition(Active, None)
+ pending --> superseded : Runs::create on same (repo, ref)\nsupersede_existing (raw SQL)
+ pending --> failed : reconcile_orphans\nfailure_kind='orphaned'
+
+ active --> complete : transition(Complete, None)
+ active --> failed : transition(Failed, 'quire-ci-exit')
+ active --> superseded : Runs::create on same (repo, ref)\ndocker kill + supersede_existing
+ active --> failed : reconcile_orphans\nfailure_kind='orphaned'
+
+ complete --> [*]
+ failed --> [*]
+ superseded --> [*]
+
+ note right of pending
+ started_at_ms IS NULL
+ finished_at_ms IS NULL
+ container_id IS NULL
+ end note
+
+ note right of active
+ started_at_ms stamped on entry
+ finished_at_ms still NULL
+ end note
+
+ note right of complete
+ started_at_ms, finished_at_ms set
+ container_id cleared
+ end note
+```
+
+### Transitions in code
+
+| From → To | Where | When | `failure_kind` |
+| --- | --- | --- | --- |
+| `[*] → pending` | `Runs::create` (`quire-server/src/ci/run.rs:99`) | A push event arrives and a `runs` row is inserted. | — |
+| `pending → active` | `Run::transition`, called from `Run::execute_via_quire_ci` | The executor begins evaluating the pipeline. Stamps `started_at_ms`. | — |
+| `active → complete` | `Run::transition` | `quire-ci` subprocess exited 0. Stamps `finished_at_ms`, clears `container_id`. | — |
+| `active → failed` | `Run::execute_via_quire_ci` | `quire-ci` subprocess exited non-zero (compile error in `.quire/ci.fnl`, failing job, or panic). | `"quire-ci-exit"` |
+| `{pending, active} → superseded` | `Runs::supersede_existing` (`run.rs:144`) via raw SQL, **bypassing `transition`** | A new `Runs::create` for the same `(repo, ref)` arrived. Pending rows are flipped directly; active rows have their container killed (`docker kill`) first. | — |
+| `{pending, active} → failed` | `reconcile_orphans` (`run.rs:194`) via raw SQL, **bypassing `transition`** | Startup-time cleanup of rows left behind by a previous `quire serve` instance. | `"orphaned"` |
+
+`Run::transition(to, failure_kind)`'s allowed-transition match:
+
+```
+(Pending, Active) | (Pending, Complete) | (Pending, Superseded) |
+(Active, Complete) | (Active, Failed) | (Active, Superseded)
+```
+
+In practice only `(Pending, Active)`, `(Active, Complete)`, and `(Active, Failed)` are exercised — the supersede edges go through raw SQL (`supersede_existing`), not `transition`. The other edges are gated for defensive consistency, in case a future caller routes supersede through the typed API. Anything else — `Pending → Failed`, `Active → Pending`, or any transition out of a terminal state — returns `InvalidTransition`.
+
+`failure_kind` is recorded only when `to == Failed`; it's ignored for `Active`, `Complete`, and `Superseded`.
+
+### Database invariants
+
+The DB enforces shape per state via a `CHECK` constraint in `migrations/0001_initial.sql`:
+
+| State | started_at | finished_at | container_id |
+| --- | --- | --- | --- |
+| `pending` | NULL | NULL | NULL |
+| `active` | set | NULL | (any) |
+| `complete` | set | set | NULL |
+| `failed` | (any) | set | NULL |
+| `superseded` | (any) | set | NULL |
+
+Plus monotonicity: `started_at >= queued_at`, `finished_at >= started_at`. `started_at_ms`, `finished_at_ms`, and `failure_kind` are stamped at most once each, via `COALESCE` in the `UPDATE`.
+
+### `failure_kind`
+
+Nullable column populated by `Run::transition` when entering `Failed`, plus `reconcile_orphans` (raw SQL). Each transition sets it at most once via `COALESCE`. The values written today:
+
+| Value | Producer |
+| --- | --- |
+| `"quire-ci-exit"` | `Run::execute_via_quire_ci`: subprocess exited non-zero. Covers both compile errors in `ci.fnl` (caught inside `quire-ci`) and failing user jobs. |
+| `"orphaned"` | `reconcile_orphans` on startup. |
+
+Successful and superseded runs leave `failure_kind` NULL. The set is open — UI consumers should not assume it's exhaustive.
+
+## Job state machine
+
+### Diagram
+
+```mermaid
+stateDiagram-v2
+ [*] --> complete : ingest JobFinished(outcome='complete')
+ [*] --> failed : ingest JobFinished(outcome='failed')
+
+ complete --> [*]
+ failed --> [*]
+
+ pending --> [*] : no producer yet — see Gaps
+ active --> [*] : no producer yet — see Gaps
+ skipped --> [*] : no producer yet — see Gaps
+ aborted --> [*] : no producer yet — see Gaps
+```
+
+### Transitions in code
+
+There is only one writer of `jobs` rows: `Run::ingest_events` (`run.rs:354`). It reads `events.jsonl` after the `quire-ci` subprocess exits and, for each `JobStarted`/`JobFinished` pair, inserts **one row directly in the terminal state**. The intermediate `active` state is held in an in-memory `pending_jobs` map during ingest and never persisted.
+
+| From → To | Where | When |
+| --- | --- | --- |
+| `[*] → complete` | `Run::ingest_events` (`run.rs:354`) | `JobFinished { outcome: complete }` paired with a buffered `JobStarted`. |
+| `[*] → failed` | `Run::ingest_events` | `JobFinished { outcome: failed }` paired with a buffered `JobStarted`. |
+
+Consequence: while `quire-ci` is running, **no `jobs` rows exist for this run**. They all materialize at ingest time. Live progress is visible via `events.jsonl` or per-`sh` log files on disk, not via SQL.
+
+### Database invariants
+
+`migrations/0001_initial.sql` allows six job states (`pending`, `active`, `complete`, `failed`, `skipped`, `aborted`) with these shape rules:
+
+| State | started_at | finished_at |
+| --- | --- | --- |
+| `pending` | NULL | NULL |
+| `active` | set | NULL |
+| `complete` | set | set |
+| `failed` | set | set |
+| `skipped` | NULL | set |
+| `aborted` | (any) | set |
+
+`skipped` carries `finished_at` but not `started_at` — the row exists to record "this job never ran" with a timestamp anchoring it to the run.
+
+### Stop-on-first-failure inside `quire-ci`
+
+The subprocess's executor (`quire-ci/src/main.rs`) breaks out of the topo-order loop on the first job error:
+
+```rust
+if let Err(e) = result {
+ failed_job = Some((job_id.clone(), e));
+ break;
+}
+```
+
+`JobStarted`/`JobFinished` are only emitted for jobs that actually ran. **Jobs downstream of the failure produce no events, so no `jobs` row at all** — not `skipped`, not anything. See Gaps below.
+
+## Event flow: QuireCi executor
+
+`Executor::QuireCi` is the only executor today. The orchestrator shells out to the `quire-ci` binary and ingests events afterward, rather than driving the runtime in-process:
+
+```mermaid
+sequenceDiagram
+ participant Trigger as ci::trigger_ref
+ participant Run as Run (server)
+ participant CI as quire-ci subprocess
+ participant DB as SQLite
+
+ Trigger->>Run: execute_via_quire_ci()
+ Run->>DB: UPDATE runs SET state='active'
+ Run->>CI: spawn (--dispatch, --events, --out-dir)
+ CI->>CI: compile .quire/ci.fnl
+ loop per job in topo order
+ CI->>CI: enter_job / run-fn / leave_job
+ CI->>CI: append JobStarted/ShStarted/ShFinished/JobFinished\nto events.jsonl
+ end
+ CI-->>Run: exit status
+ Run->>Run: ingest_events(events.jsonl)
+ Run->>DB: INSERT jobs (pass 1)
+ Run->>DB: INSERT sh_events (pass 2)
+ alt exit 0
+ Run->>DB: UPDATE runs SET state='complete'
+ else exit nonzero
+ Run->>DB: UPDATE runs SET state='failed' (failure_kind='quire-ci-exit')
+ end
+```
+
+Wire events (`quire-core/src/ci/event.rs`):
+
+* `JobStarted { job_id }`
+* `JobFinished { job_id, outcome: complete | failed }` — `JobOutcome` is the closed set, not the full job-state enum.
+* `ShStarted { job_id, cmd }` / `ShFinished { job_id, exit_code }`
+
+`Run::ingest_events` reads the file in two passes (jobs first to satisfy the FK on `(run_id, job_id)`, then sh_events). Ingest failures are logged but never demote the run's own outcome — a partial DB write is preferable to losing the pass/fail signal.
+
+## Orchestration today
+
+The lifecycle from push to run start:
+
+```mermaid
+sequenceDiagram
+ participant Hook as post-receive
+ participant Listener as event_listener (tokio)
+ participant Trigger as ci::trigger
+ participant Exec as Run::execute_via_quire_ci
+ participant FS as filesystem
+ participant DB as SQLite
+
+ Hook->>Listener: PushEvent JSON over /var/quire/server.sock
+ Listener->>Trigger: trigger(quire, &event)
+ loop per updated ref
+ Trigger->>DB: supersede_existing (Pending|Active → Superseded for same repo/ref)
+ Trigger->>DB: INSERT runs (state=pending)
+ Trigger->>FS: create run dir + workspace
+ Trigger->>FS: git archive | tar -x (materialize workspace)
+ Trigger->>Exec: execute_via_quire_ci
+ Exec->>DB: pending → active → complete|failed
+ end
+```
+
+Two things in `CI.md` that the code does *not* yet implement at this layer:
+
+* **Queue + Notify wakeup.** `CI.md` describes a separate runner task pulled from a SQLite queue via `tokio::sync::Notify`. Today `ci::trigger` is called **synchronously** on the listener's tokio task — one push at a time, no queue, no separate runner. Max-concurrency-1 falls out of this trivially, but it isn't the architecture in `CI.md`.
+* **Per-run container.** `CI.md` says `docker run` at run start, `docker exec` per `(sh …)`, `docker stop` at end. `quire-ci` invokes `(sh …)` directly on the host process; the `container_id` / `container_started_at_ms` columns are populated only by the dev fixture and read by `supersede_existing` (which already calls `docker kill` against whatever's there).
+
+## Gaps
+
+States the schema admits — or `CI.md` commits to — that no code path produces today:
+
+| Gap | Schema/spec | Producer needed |
+| --- | --- | --- |
+| Job `active` rows during execution | Schema-allowed | `ingest_events` inserts one row per job at JobFinished time. While `quire-ci` is running, the `jobs` table has nothing for this run. Live UI of "currently running job" needs an active-row writer — either eager ingest, or a separate writer inside `quire-ci`. |
+| Job `pending` rows | Schema-allowed | Useful for "queued jobs in topo order" UI. Today jobs go straight from no-row to a terminal row. |
+| Job `skipped` rows for dependents of a failed job | Schema-allowed | `quire-ci`'s loop `break`s on first failure and emits no events for downstream jobs. To populate `skipped`, either `quire-ci` would emit `JobSkipped` events for unrun topo-order jobs, or the ingester would compute them from the pipeline graph + the surviving JobFinished rows. |
+| Job `aborted` rows | Schema-allowed | Needed when a run is killed mid-flight — e.g. by `supersede_existing` `docker kill`-ing the container. Today the run row flips to `superseded`, but no `jobs` rows are written for the work that was in flight. |
+| `:allow-failure` job flag | Documented in `CI.md` as v1 | Not implemented anywhere in `quire-core`, `quire-ci`, or `quire-server`. The structural validator doesn't recognize the key; the executor treats every job error as fatal. |
+| Queue + Notify wakeup | `CI.md` "Communication" section | `trigger` runs synchronously on the listener task. No queue scan, no Notify, no separate runner task. |
+
+## Cross-references
+
+* Architecture and rationale: [`CI.md`](./CI.md).
+* Pipeline DSL: [`CI-FENNEL.md`](./CI-FENNEL.md).
+* DB shape: [`quire-server/migrations/0001_initial.sql`](../quire-server/migrations/0001_initial.sql).
+* Code: `quire-server/src/ci/run.rs`, `quire-server/src/ci/mod.rs`, `quire-core/src/ci/event.rs`, `quire-ci/src/main.rs`.
diff --git a/docs/CI.md b/docs/CI.md
index 2ff2ece..a9f4a75 100644
--- a/docs/CI.md
+++ b/docs/CI.md
@@ -1,6 +1,6 @@
# quire — CI design
-How CI works in quire. Slots alongside PLAN.md; will likely fold in once the open questions settle.
+How CI works in quire. Slots alongside PLAN.md; will likely fold in once the open questions settle. For the run/job state machines and what each state means in the database, see [CI-STATE.md](./CI-STATE.md).
## Shape
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 5ee9b4e..2c47aac 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -277,7 +277,7 @@ impl Run {
secrets: &HashMap<String, SecretString>,
sentry: Option<&quire_core::ci::dispatch::SentryHandoff>,
) -> Result<()> {
- self.transition(RunState::Active)?;
+ self.transition(RunState::Active, None)?;
let run_dir = self.path();
let log_path = run_dir.join("quire-ci.log");
@@ -329,13 +329,13 @@ impl Run {
}
if !status.success() {
- self.transition(RunState::Failed)?;
+ self.transition(RunState::Failed, Some("quire-ci-exit"))?;
return Err(Error::QuireCiExit {
exit: status.code(),
});
}
- self.transition(RunState::Complete)?;
+ self.transition(RunState::Complete, None)?;
Ok(())
}
@@ -416,11 +416,22 @@ impl Run {
/// Transition the run from its current state to a new state.
///
- /// Executes a single `UPDATE` in the database, stamping
- /// `started_at` (entering Active) or `finished_at` (entering
- /// Complete or Failed) and clearing `container_id` on terminal
- /// states. Each timestamp is set at most once.
- pub fn transition(&mut self, to: RunState) -> Result<()> {
+ /// Allowed edges (see `docs/CI-STATE.md`):
+ ///
+ /// * `Pending → Active`
+ /// * `Pending → Complete`
+ /// * `Pending → Superseded`
+ /// * `Active → Complete`
+ /// * `Active → Failed`
+ /// * `Active → Superseded`
+ ///
+ /// `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),
@@ -441,7 +452,6 @@ impl Run {
let now = Timestamp::now().as_millisecond();
let db = crate::db::open(&self.db_path)?;
- // Build the SET clause dynamically based on the target state.
match to {
Active => {
db.execute(
@@ -450,7 +460,7 @@ impl Run {
rusqlite::params![now, &self.id],
)?;
}
- Complete | Failed | Superseded => {
+ Complete | Superseded => {
db.execute(
"UPDATE runs SET state = ?1, \
started_at_ms = COALESCE(started_at_ms, ?2), \
@@ -460,6 +470,17 @@ impl Run {
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), \
+ container_id = NULL, \
+ failure_kind = COALESCE(failure_kind, ?3) \
+ WHERE id = ?4",
+ rusqlite::params![now, now, failure_kind, &self.id],
+ )?;
+ }
Pending => unreachable!("transition to Pending is not valid"),
}
@@ -804,7 +825,7 @@ mod tests {
let mut run = runs.create(&test_meta()).expect("create");
let id = run.id().to_string();
- run.transition(RunState::Active).expect("transition");
+ run.transition(RunState::Active, None).expect("transition");
assert_eq!(run.state(), RunState::Active);
// Verify started_at was stamped.
@@ -824,7 +845,7 @@ mod tests {
let runs = test_runs(&quire);
let mut run = runs.create(&test_meta()).expect("create");
- run.transition(RunState::Active).expect("to active");
+ 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());
@@ -836,18 +857,67 @@ mod tests {
let runs = test_runs(&quire);
let mut completed = runs.create(&test_meta()).expect("create");
- completed.transition(RunState::Active).expect("to active");
completed
- .transition(RunState::Complete)
+ .transition(RunState::Active, None)
+ .expect("to active");
+ completed
+ .transition(RunState::Complete, None)
.expect("to complete");
assert!(completed.read_finished_at().expect("read").is_some());
let mut failed = runs.create(&test_meta()).expect("create");
- failed.transition(RunState::Active).expect("to active");
- failed.transition(RunState::Failed).expect("to failed");
+ 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());
}
+ #[test]
+ fn transition_records_failure_kind_on_failed() {
+ let (_dir, quire) = tmp_quire();
+ let runs = test_runs(&quire);
+ let mut run = runs.create(&test_meta()).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");
+
+ 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"));
+ }
+
+ #[test]
+ fn transition_skips_failure_kind_when_none() {
+ let (_dir, quire) = tmp_quire();
+ let runs = test_runs(&quire);
+ let mut run = runs.create(&test_meta()).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());
+ }
+
#[test]
fn transition_rejects_invalid_transitions() {
let (_dir, quire) = tmp_quire();
@@ -855,16 +925,18 @@ mod tests {
// Pending -> Failed is not allowed (must go via Active).
let mut run = runs.create(&test_meta()).expect("create");
- assert!(run.transition(RunState::Failed).is_err());
+ assert!(run.transition(RunState::Failed, None).is_err());
// Terminal -> anything is not allowed.
let mut completed = runs.create(&test_meta()).expect("create");
- completed.transition(RunState::Active).expect("to active");
completed
- .transition(RunState::Complete)
+ .transition(RunState::Active, None)
+ .expect("to active");
+ completed
+ .transition(RunState::Complete, None)
.expect("to complete");
- assert!(completed.transition(RunState::Active).is_err());
- assert!(completed.transition(RunState::Failed).is_err());
+ assert!(completed.transition(RunState::Active, None).is_err());
+ assert!(completed.transition(RunState::Failed, None).is_err());
}
#[test]
@@ -873,10 +945,11 @@ mod tests {
let runs = test_runs(&quire);
let mut run = runs.create(&test_meta()).expect("create");
- run.transition(RunState::Active).expect("to active");
+ run.transition(RunState::Active, None).expect("to active");
let started = run.read_started_at().expect("read started_at");
- run.transition(RunState::Complete).expect("to complete");
+ run.transition(RunState::Complete, None)
+ .expect("to complete");
assert_eq!(
run.read_started_at().expect("read"),
started,
@@ -890,8 +963,9 @@ mod tests {
let runs = test_runs(&quire);
let mut run = runs.create(&test_meta()).expect("create");
- run.transition(RunState::Active).expect("to active");
- run.transition(RunState::Complete).expect("to complete");
+ run.transition(RunState::Active, None).expect("to active");
+ run.transition(RunState::Complete, None)
+ .expect("to complete");
assert_eq!(run.state(), RunState::Complete);
}
@@ -914,7 +988,7 @@ mod tests {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
let mut run = runs.create(&test_meta()).expect("create");
- run.transition(RunState::Active).expect("to active");
+ run.transition(RunState::Active, None).expect("to active");
let id = run.id().to_string();
reconcile_orphans(&quire.db_path()).expect("reconcile");
@@ -928,8 +1002,9 @@ mod tests {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
let mut run = runs.create(&test_meta()).expect("create");
- run.transition(RunState::Active).expect("to active");
- run.transition(RunState::Complete).expect("to complete");
+ run.transition(RunState::Active, None).expect("to active");
+ run.transition(RunState::Complete, None)
+ .expect("to complete");
let id = run.id().to_string();
reconcile_orphans(&quire.db_path()).expect("reconcile");
@@ -1108,7 +1183,7 @@ mod tests {
// Create and activate first run.
let mut run1 = runs.create(&test_meta()).expect("create run1");
let run1_id = run1.id().to_string();
- run1.transition(RunState::Active).expect("to active");
+ run1.transition(RunState::Active, None).expect("to active");
// Create second run for same (repo, ref).
let meta2 = RunMeta {
@@ -1158,8 +1233,9 @@ mod tests {
// Create and complete first run.
let mut run1 = runs.create(&test_meta()).expect("create run1");
let run1_id = run1.id().to_string();
- run1.transition(RunState::Active).expect("to active");
- run1.transition(RunState::Complete).expect("to complete");
+ run1.transition(RunState::Active, None).expect("to active");
+ run1.transition(RunState::Complete, None)
+ .expect("to complete");
// Create second run for same (repo, ref).
let meta2 = RunMeta {
@@ -1179,7 +1255,8 @@ mod tests {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
let mut run = runs.create(&test_meta()).expect("create");
- run.transition(RunState::Superseded).expect("to superseded");
+ run.transition(RunState::Superseded, None)
+ .expect("to superseded");
assert_eq!(run.state(), RunState::Superseded);
}
@@ -1188,8 +1265,9 @@ mod tests {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
let mut run = runs.create(&test_meta()).expect("create");
- run.transition(RunState::Active).expect("to active");
- run.transition(RunState::Superseded).expect("to superseded");
+ run.transition(RunState::Active, None).expect("to active");
+ run.transition(RunState::Superseded, None)
+ .expect("to superseded");
assert_eq!(run.state(), RunState::Superseded);
}
@@ -1198,14 +1276,15 @@ mod tests {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
let mut run = runs.create(&test_meta()).expect("create");
- run.transition(RunState::Active).expect("to active");
+ run.transition(RunState::Active, None).expect("to active");
assert!(
run.read_finished_at().expect("read").is_none(),
"should not have finished_at before supersede"
);
- run.transition(RunState::Superseded).expect("to superseded");
+ run.transition(RunState::Superseded, None)
+ .expect("to superseded");
assert!(
run.read_finished_at().expect("read").is_some(),
"superseded run should have finished_at"