Rename dispatch → bootstrap across the codebase
The `Dispatch` struct and its wire format were named after the act of
dispatching a run to quire-ci, not what the resource actually is. As a
REST endpoint `/api/runs/:id/dispatch` reads like a verb-trigger rather
than a noun resource. Rename everything to `bootstrap` since that's what
quire-ci uses the payload for — bootstrapping a run — and what the design
doc's own lifecycle description calls it.
- `quire-core/src/ci/dispatch.rs` → `bootstrap.rs`; `Dispatch` → `Bootstrap`
- `dispatch.json` on disk → `bootstrap.json`
- `--dispatch` CLI flag → `--bootstrap`
- `write_dispatch` / `load_dispatch` → `write_bootstrap` / `load_bootstrap`
- `/api/runs/:id/dispatch` in design doc → `/api/runs/:id/bootstrap`
https://claude.ai/code/session_01LEsqtS6DbBkGyyzNM4iRz1
diff --git a/docs/CI-STATE.md b/docs/CI-STATE.md
index a641ba7..00bd2fe 100644
--- a/docs/CI-STATE.md
+++ b/docs/CI-STATE.md
@@ -164,7 +164,7 @@ sequenceDiagram
Trigger->>Run: execute_via_quire_ci()
Run->>DB: UPDATE runs SET state='active'
- Run->>CI: spawn (--dispatch, --events, --out-dir)
+ Run->>CI: spawn (--bootstrap, --events, --out-dir)
CI->>CI: compile .quire/ci.fnl
loop per job in topo order
CI->>CI: enter_job / run-fn / leave_job
diff --git a/docs/plans/2026-05-14-quire-ci-server-api-design.md b/docs/plans/2026-05-14-quire-ci-server-api-design.md
index 7aa153a..2819434 100644
--- a/docs/plans/2026-05-14-quire-ci-server-api-design.md
+++ b/docs/plans/2026-05-14-quire-ci-server-api-design.md
@@ -27,7 +27,7 @@ Out of scope (deliberately deferred to v2):
| Artifact | Path | Writer | Reader | Purpose |
|---|---|---|---|---|
-| Dispatch | `<run-dir>/dispatch.json` | server | CI | Secrets, git_dir, push meta, Sentry handoff |
+| Bootstrap | `<run-dir>/bootstrap.json` | server | CI | Secrets, git_dir, push meta, Sentry handoff |
| Events | `<run-dir>/events.jsonl` | CI | server | Job/sh state transitions |
| Subprocess log | `<run-dir>/quire-ci.log` | CI (stdio) | server UI | Debug subprocess output |
| Per-sh log | `<run-dir>/jobs/<job>/sh-<n>.log` | CI | server UI | Rendered on job detail page |
@@ -61,7 +61,7 @@ Endpoints (CI calls server):
| Method | Path | Body | Purpose |
|---|---|---|---|
-| GET | `/api/runs/:id/dispatch` | — | Fetch dispatch payload. One-shot: server invalidates after first successful read. |
+| GET | `/api/runs/:id/bootstrap` | — | Fetch bootstrap payload. One-shot: server invalidates after first successful read. |
| POST | `/api/runs/:id/jobs/:job_id/start` | `{}` | Job entered execution. Server timestamps. |
| POST | `/api/runs/:id/jobs/:job_id/finish` | `{ outcome: "complete" \| "failed" }` | Job done. |
| POST | `/api/runs/:id/jobs/:job_id/sh/start` | `{ cmd: string }` | Start the next sh in this job. Server assigns the sh index. |
@@ -78,7 +78,7 @@ Ordering rules enforced server-side, returning 409 on violation:
- `jobs/:id/finish` requires no sh currently open.
- `complete` requires every started job to have finished.
-Other error codes: 401 for missing/invalid token, 403 for token/run mismatch, 404 for unknown run, 410 if `dispatch` was already fetched, 422 if a path segment doesn't match a known job, 5xx for server errors (CI retries with backoff).
+Other error codes: 401 for missing/invalid token, 403 for token/run mismatch, 404 for unknown run, 410 if `bootstrap` was already fetched, 422 if a path segment doesn't match a known job, 5xx for server errors (CI retries with backoff).
## Lifecycle
@@ -93,9 +93,9 @@ Other error codes: 401 for missing/invalid token, 403 for token/run mismatch, 40
--workspace <path>
```
- No `--dispatch`, no `--events`, no log path. The workspace path remains because that's the checkout CI runs *in*, not a comms channel.
+ No `--bootstrap`, no `--events`, no log path. The workspace path remains because that's the checkout CI runs *in*, not a comms channel.
-3. **quire-ci bootstraps.** First call is `GET /api/runs/:id/dispatch`. Server flips the run to `active`, returns the dispatch payload (secrets, git_dir, push meta, Sentry handoff), and invalidates the dispatch resource. Watchdog timer starts.
+3. **quire-ci bootstraps.** First call is `GET /api/runs/:id/bootstrap`. Server flips the run to `active`, returns the bootstrap payload (secrets, git_dir, push meta, Sentry handoff), and invalidates the bootstrap resource. Watchdog timer starts.
4. **quire-ci executes the DAG.** For each job:
- `POST /jobs/:job_id/start`
@@ -123,7 +123,7 @@ Server-side validation runs before any DB work:
quire-ci retry policy:
- **Events (start/finish endpoints):** retry on 5xx with exponential backoff, up to ~5 attempts. On final failure, log to stderr and continue. Losing a state-transition POST is bad but should not kill the run.
-- **Dispatch:** retry on 5xx, fail fast on 4xx. If dispatch cannot be fetched, the run is unrunnable; CI exits nonzero and the watchdog catches it.
+- **Bootstrap:** retry on 5xx, fail fast on 4xx. If bootstrap cannot be fetched, the run is unrunnable; CI exits nonzero and the watchdog catches it.
- **Complete:** the most important POST in the run. Retry with backoff and more attempts than the others.
- **Log streams:** v1 does not retry. A broken stream logs a stderr warning and execution continues.
@@ -131,7 +131,7 @@ Server-side robustness:
- All state-transition endpoints are idempotent by virtue of state checks: a retried `/start` after a successful first call gets 409, and CI treats that as "already recorded, move on."
- Watchdog: per-run last-contact timestamp, reset on every authenticated request. Configurable timeout (default tuned long enough for slow `npm install`-style steps, short enough that crashes don't strand runs).
-- Sentry handoff is still passed via the dispatch payload, so CI traces continue to link to server traces.
+- Sentry handoff is still passed via the bootstrap payload, so CI traces continue to link to server traces.
## Rollout
@@ -143,7 +143,7 @@ A server-side config flag keeps the filesystem path working while the API is bui
...}
```
-Default is `:filesystem` during build-out. The server reads this at startup and, when spawning quire-ci, passes the choice through (CLI flag or env var — implementation detail). quire-ci implements both paths and dispatches at startup. Each slice (dispatch, jobs/sh state, logs, complete) checks the flag and takes either path.
+Default is `:filesystem` during build-out. The server reads this at startup and, when spawning quire-ci, passes the choice through (CLI flag or env var — implementation detail). quire-ci implements both paths and dispatches at startup. Each slice (bootstrap, jobs/sh state, logs, complete) checks the flag and takes either path.
The flag lives in server config rather than per-repo `ci.fnl` because:
@@ -160,7 +160,7 @@ After every slice ships and the API path proves out, delete the filesystem path
These will be filed as separate ranger tasks:
1. **Auth + transport foundation.** Bearer-token middleware on server. HTTP client in quire-ci. CLI args (`--run-id`, `--server-url`) and `QUIRE_CI_TOKEN` env var threaded through. Config flag wired up. No behavior change yet — both halves still take the filesystem path.
-2. **Dispatch over the API.** `GET /api/runs/:id/dispatch` and corresponding client call, gated by the transport flag.
+2. **Bootstrap over the API.** `GET /api/runs/:id/bootstrap` and corresponding client call, gated by the transport flag.
3. **Job and sh state transitions over the API.** The four `/start` and `/finish` endpoints, plus their clients.
4. **Log streaming over the API.** Chunked `POST /sh/logs` with CRI-line bodies.
5. **`/complete` and watchdog.** Authoritative completion, server-side timeout backstop.
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index ff90cb6..8cc34e2 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -54,8 +54,8 @@ enum Commands {
/// Run the whole pipeline against the workspace, in topo order.
///
- /// `--dispatch <path>` points at a JSON file (see
- /// [`quire_core::ci::dispatch::Dispatch`]) that supplies push
+ /// `--bootstrap <path>` points at a JSON file (see
+ /// [`quire_core::ci::bootstrap::Bootstrap`]) that supplies push
/// metadata and secrets when the orchestrator dispatches via
/// `:executor :quire-ci`. Standalone invocations omit the flag
/// and fall back to placeholder meta with no secrets — `(secret
@@ -77,12 +77,12 @@ enum Commands {
#[arg(long)]
out_dir: Option<PathBuf>,
- /// Path to a JSON dispatch file produced by the orchestrator.
+ /// Path to a JSON bootstrap file produced by the orchestrator.
/// Carries push metadata and the secrets the run-fns may
/// resolve. Omit for standalone runs (placeholder meta, no
/// secrets).
#[arg(long)]
- dispatch: Option<PathBuf>,
+ bootstrap: Option<PathBuf>,
#[command(flatten)]
transport: TransportFlags,
@@ -235,7 +235,7 @@ fn main() -> miette::Result<()> {
Commands::Run {
events,
out_dir,
- dispatch,
+ bootstrap,
transport,
} => {
let sink: Box<dyn EventSink> = match events {
@@ -259,8 +259,8 @@ fn main() -> miette::Result<()> {
};
let auth_token = std::env::var("QUIRE_CI_TOKEN").ok();
let transport = transport.resolve(auth_token)?;
- let (git_dir, meta, secrets, sentry_handoff) = match dispatch {
- Some(path) => load_dispatch(&path)?,
+ let (git_dir, meta, secrets, sentry_handoff) = match bootstrap {
+ Some(path) => load_bootstrap(&path)?,
None => (
cli.workspace.join(".git"),
placeholder_meta(),
@@ -313,7 +313,7 @@ fn main() -> miette::Result<()> {
/// trace_id (shouldn't happen — the orchestrator emits the canonical
/// hex form) is logged and skipped rather than aborting Sentry init.
fn init_sentry(
- handoff: Option<&quire_core::ci::dispatch::SentryHandoff>,
+ handoff: Option<&quire_core::ci::bootstrap::SentryHandoff>,
meta: &RunMeta,
) -> Option<sentry::ClientInitGuard> {
let handoff = handoff?;
@@ -341,7 +341,7 @@ fn init_sentry(
tracing::warn!(
trace_id = %handoff.trace_id,
error = %e,
- "malformed trace_id in dispatch; quire-ci events won't link to orchestrator",
+ "malformed trace_id in bootstrap; quire-ci events won't link to orchestrator",
);
}
}
@@ -372,7 +372,7 @@ fn validate(workspace: PathBuf) -> miette::Result<()> {
}
/// Standalone runs synthesize a placeholder `quire/push`. Real meta
-/// arrives via `--dispatch` from the orchestrator.
+/// arrives via `--bootstrap` from the orchestrator.
fn placeholder_meta() -> RunMeta {
RunMeta {
sha: "0".repeat(40),
@@ -381,27 +381,27 @@ fn placeholder_meta() -> RunMeta {
}
}
-/// Read and parse the dispatch file the orchestrator wrote before
+/// Read and parse the bootstrap file the orchestrator wrote before
/// spawning. Wraps revealed secret values back into `SecretString`.
///
/// Unlinks the file as soon as the bytes are in memory — secrets only
-/// need to live on disk for the moment between `write_dispatch` and
+/// need to live on disk for the moment between `write_bootstrap` and
/// this read, and getting them off disk early limits the blast radius
/// of a later panic or crash leaving a 0600 file behind.
///
/// The Sentry handoff, when present, carries the DSN and the
-/// orchestrator's trace id — the 0600 dispatch file is the line of
+/// orchestrator's trace id — the 0600 bootstrap file is the line of
/// defense for both.
#[allow(clippy::type_complexity)]
-fn load_dispatch(
+fn load_bootstrap(
path: &std::path::Path,
) -> miette::Result<(
PathBuf,
RunMeta,
HashMap<String, quire_core::secret::SecretString>,
- Option<quire_core::ci::dispatch::SentryHandoff>,
+ Option<quire_core::ci::bootstrap::SentryHandoff>,
)> {
- use quire_core::ci::dispatch::Dispatch;
+ use quire_core::ci::bootstrap::Bootstrap;
use quire_core::secret::SecretString;
let bytes = fs_err::read(path).into_diagnostic()?;
@@ -410,17 +410,17 @@ fn load_dispatch(
// will best-effort unlink after we exit. But this is a
// security-relevant cleanup, so it's worth surfacing.
eprintln!(
- "warning: failed to remove dispatch file {}: {e}",
+ "warning: failed to remove bootstrap file {}: {e}",
path.display()
);
}
- let dispatch: Dispatch = serde_json::from_slice(&bytes).into_diagnostic()?;
- let secrets = dispatch
+ let bootstrap: Bootstrap = serde_json::from_slice(&bytes).into_diagnostic()?;
+ let secrets = bootstrap
.secrets
.into_iter()
.map(|(name, value)| (name, SecretString::from(value)))
.collect();
- Ok((dispatch.git_dir, dispatch.meta, secrets, dispatch.sentry))
+ Ok((bootstrap.git_dir, bootstrap.meta, secrets, bootstrap.sentry))
}
fn run_pipeline(
@@ -620,12 +620,12 @@ mod tests {
}
#[test]
- fn load_dispatch_unlinks_after_read() {
- use quire_core::ci::dispatch::Dispatch;
+ fn load_bootstrap_unlinks_after_read() {
+ use quire_core::ci::bootstrap::Bootstrap;
let dir = tempfile::tempdir().expect("tempdir");
- let path = dir.path().join("dispatch.json");
- let dispatch = Dispatch {
+ let path = dir.path().join("bootstrap.json");
+ let bootstrap = Bootstrap {
meta: RunMeta {
sha: "0".repeat(40),
r#ref: "HEAD".to_string(),
@@ -635,10 +635,13 @@ mod tests {
secrets: HashMap::from([("token".to_string(), "shh".to_string())]),
sentry: None,
};
- fs_err::write(&path, serde_json::to_vec(&dispatch).unwrap()).expect("write");
+ fs_err::write(&path, serde_json::to_vec(&bootstrap).unwrap()).expect("write");
- let (git_dir, meta, secrets, sentry) = load_dispatch(&path).expect("load");
- assert!(!path.exists(), "dispatch file should be removed after read");
+ let (git_dir, meta, secrets, sentry) = load_bootstrap(&path).expect("load");
+ assert!(
+ !path.exists(),
+ "bootstrap file should be removed after read"
+ );
assert_eq!(git_dir, PathBuf::from("/tmp/repo.git"));
assert_eq!(meta.r#ref, "HEAD");
assert_eq!(secrets.len(), 1);
diff --git a/quire-core/src/ci/dispatch.rs b/quire-core/src/ci/bootstrap.rs
similarity index 90%
rename from quire-core/src/ci/dispatch.rs
rename to quire-core/src/ci/bootstrap.rs
index 30981ba..ad90dbd 100644
--- a/quire-core/src/ci/dispatch.rs
+++ b/quire-core/src/ci/bootstrap.rs
@@ -1,8 +1,8 @@
//! Wire format for handing off a run from the orchestrator to
//! `quire-ci`.
//!
-//! The orchestrator writes a [`Dispatch`] as JSON to a file inside
-//! the run directory and passes the path via `--dispatch`. `quire-ci`
+//! The orchestrator writes a [`Bootstrap`] as JSON to a file inside
+//! the run directory and passes the path via `--bootstrap`. `quire-ci`
//! deserializes it on startup to recover push metadata and the
//! secrets the run-fns may resolve. Standalone `quire-ci run`
//! invocations skip the file entirely and fall back to placeholder
@@ -36,7 +36,7 @@ use crate::ci::run::RunMeta;
/// `git archive` extract with no `.git` inside, so quire-ci has no
/// way to recover this path on its own.
#[derive(Debug, Serialize, Deserialize)]
-pub struct Dispatch {
+pub struct Bootstrap {
pub meta: RunMeta,
pub git_dir: PathBuf,
pub secrets: HashMap<String, String>,
@@ -50,7 +50,7 @@ pub struct Dispatch {
/// What quire-ci needs to mirror the orchestrator's Sentry context.
///
/// The DSN is plaintext like the secrets — the 0600 mode on the
-/// dispatch file is the line of defense. `trace_id` is the hex form
+/// bootstrap file is the line of defense. `trace_id` is the hex form
/// of [`sentry::protocol::TraceId`]; kept as a string here so
/// `quire-core` doesn't grow a `sentry` dep.
#[derive(Debug, Serialize, Deserialize)]
diff --git a/quire-core/src/ci/mod.rs b/quire-core/src/ci/mod.rs
index 40a310e..1cf5bb8 100644
--- a/quire-core/src/ci/mod.rs
+++ b/quire-core/src/ci/mod.rs
@@ -5,7 +5,7 @@
//! (where `quire-ci` invokes them) and on the server (where the
//! orchestrator drives them).
-pub mod dispatch;
+pub mod bootstrap;
pub mod event;
pub mod logs;
pub mod pipeline;
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 06ec670..d07c747 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -192,7 +192,7 @@ fn run_ref(
let sentry_handoff =
ctx.sentry_dsn
.as_ref()
- .map(|dsn| quire_core::ci::dispatch::SentryHandoff {
+ .map(|dsn| quire_core::ci::bootstrap::SentryHandoff {
dsn: dsn.clone(),
trace_id: trace_id.to_string(),
});
@@ -233,7 +233,7 @@ fn run_ref_inner(
pushed_at: jiff::Timestamp,
push_ref: &PushRef,
transport: &Transport,
- sentry: Option<&quire_core::ci::dispatch::SentryHandoff>,
+ sentry: Option<&quire_core::ci::bootstrap::SentryHandoff>,
) -> error::Result<()> {
let ci = ctx.repo.ci();
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 6c46f52..1eacfe7 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -328,7 +328,7 @@ impl Run {
workspace: &Path,
meta: &RunMeta,
secrets: &HashMap<String, SecretString>,
- sentry: Option<&quire_core::ci::dispatch::SentryHandoff>,
+ sentry: Option<&quire_core::ci::bootstrap::SentryHandoff>,
transport: &Transport,
) -> Result<()> {
self.transition(RunState::Active, None)?;
@@ -336,13 +336,13 @@ impl Run {
let run_dir = self.path();
let log_path = run_dir.join("quire-ci.log");
let events_path = run_dir.join("events.jsonl");
- let dispatch_path = run_dir.join("dispatch.json");
+ let bootstrap_path = run_dir.join("bootstrap.json");
// fs_err for the path-bearing IO error; unwrap to std::fs::File so
// it's convertible into Stdio.
let log = fs_err::File::create(&log_path)?.into_parts().0;
let log_clone = log.try_clone()?;
- write_dispatch(&dispatch_path, git_dir, meta, secrets, sentry)?;
+ write_bootstrap(&bootstrap_path, git_dir, meta, secrets, sentry)?;
tracing::info!(
run_id = %self.id,
@@ -360,8 +360,8 @@ impl Run {
.arg(&run_dir)
.arg("--events")
.arg(&events_path)
- .arg("--dispatch")
- .arg(&dispatch_path);
+ .arg("--bootstrap")
+ .arg(&bootstrap_path);
}
Transport::Api(api) => {
cmd.arg("--run-id")
@@ -382,18 +382,18 @@ impl Run {
source,
})?;
- // quire-ci unlinks the dispatch file after `load_dispatch`;
+ // quire-ci unlinks the bootstrap file after `load_bootstrap`;
// this is a best-effort safety net for paths where it didn't
// get that far (spawn failed mid-exec, arg parsing rejected
// input, panic before read). `NotFound` is the expected case.
- if let Err(e) = fs_err::remove_file(&dispatch_path)
+ if let Err(e) = fs_err::remove_file(&bootstrap_path)
&& e.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!(
run_id = %self.id,
- path = %dispatch_path.display(),
+ path = %bootstrap_path.display(),
error = %e,
- "failed to remove dispatch file after run"
+ "failed to remove bootstrap file after run"
);
}
@@ -638,18 +638,18 @@ impl Run {
}
}
-/// Serialize the dispatch payload as JSON and write it to `path` with
+/// Serialize the bootstrap payload as JSON and write it to `path` with
/// owner-only permissions on Unix. Secrets cross as plaintext so the
/// 0600 mode is the line of defense against other local users; failure
-/// to set the mode aborts the dispatch (better than leaking).
-fn write_dispatch(
+/// to set the mode aborts the write (better than leaking).
+fn write_bootstrap(
path: &Path,
git_dir: &Path,
meta: &RunMeta,
secrets: &HashMap<String, SecretString>,
- sentry: Option<&quire_core::ci::dispatch::SentryHandoff>,
+ sentry: Option<&quire_core::ci::bootstrap::SentryHandoff>,
) -> Result<()> {
- use quire_core::ci::dispatch::{Dispatch, SentryHandoff};
+ use quire_core::ci::bootstrap::{Bootstrap, SentryHandoff};
let mut revealed: HashMap<String, String> = HashMap::with_capacity(secrets.len());
for (name, value) in secrets {
@@ -658,7 +658,7 @@ fn write_dispatch(
value.reveal().map_err(Error::Secret)?.to_string(),
);
}
- let dispatch = Dispatch {
+ let bootstrap = Bootstrap {
meta: meta.clone(),
git_dir: git_dir.to_path_buf(),
secrets: revealed,
@@ -667,7 +667,7 @@ fn write_dispatch(
trace_id: s.trace_id.clone(),
}),
};
- let json = serde_json::to_vec_pretty(&dispatch).map_err(std::io::Error::other)?;
+ let json = serde_json::to_vec_pretty(&bootstrap).map_err(std::io::Error::other)?;
// Open with mode 0600 from the start so there's no window where
// the file is world-readable.
@@ -867,26 +867,26 @@ mod tests {
}
#[test]
- fn write_dispatch_records_git_dir_for_quire_ci() {
- use quire_core::ci::dispatch::Dispatch;
+ fn write_bootstrap_records_git_dir_for_quire_ci() {
+ use quire_core::ci::bootstrap::Bootstrap;
let dir = tempfile::tempdir().expect("tempdir");
- let dispatch_path = dir.path().join("dispatch.json");
+ let bootstrap_path = dir.path().join("bootstrap.json");
let git_dir = dir.path().join("repos").join("test.git");
- write_dispatch(
- &dispatch_path,
+ write_bootstrap(
+ &bootstrap_path,
&git_dir,
&test_meta(),
&HashMap::new(),
None,
)
- .expect("write_dispatch");
+ .expect("write_bootstrap");
- let bytes = fs_err::read(&dispatch_path).expect("read dispatch");
- let dispatch: Dispatch = serde_json::from_slice(&bytes).expect("parse dispatch");
+ let bytes = fs_err::read(&bootstrap_path).expect("read bootstrap");
+ let bootstrap: Bootstrap = serde_json::from_slice(&bytes).expect("parse bootstrap");
assert_eq!(
- dispatch.git_dir, git_dir,
+ bootstrap.git_dir, git_dir,
"quire-ci needs the bare repo path to set GIT_DIR for the mirror job"
);
}