feat: add a live view for in-progress check runs
commit
622810afeat: add a live view for in-progress check runs
A check run page now shows its terminal live while queued or running, polling a small fragment endpoint that renders the current screen off the worker in-progress asciicast buffer via acdc static terminal block, then reloads once the check settles. A finished check with no output reports its exit code instead of an empty replay box.
feat: capture a check process exit code in its recorded outcome feat: stream a running check pty output into a shared live buffer feat: add a live-fragment polling endpoint for a running check feat: link to in-progress checks from the Checks tab, not just finished ones Assisted-by: Claude:claude-sonnet-5
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
crates/git-ents-server/src/asciidoc.rs
@@ -82,6 +82,93 @@
recording.lines().skip(1).all(|line| line.trim().is_empty())
}
+/// Render the *current* screen of an in-progress asciicast v2 recording as a
+/// static terminal snapshot via acdc's plain `[terminal]` block (no replay
+/// scrubber — a running check has no fixed timeline yet, just a screen that
+/// keeps changing), or `None` if it cannot be parsed or converted. The
+/// asciicast recording stays the single source of truth for the check's
+/// output; this only reconstitutes the raw bytes acdc's terminal emulator
+/// needs; unlike [`render_recording`], it does not go through acdc's asciicast
+/// parser, since that produces a scrubbable timeline rather than one snapshot.
+pub(crate) fn render_live(recording: &str) -> Option<String> {
+ let ansi = extract_output(recording);
+ let source = format!("[terminal]\n----\n{ansi}\n----\n");
+ let parsed = acdc_parser::parse(&source, &ParseOptions::default()).ok()?;
+ let doc = parsed.document();
+ let processor = Processor::new(ConvertOptions::default(), doc.attributes.clone());
+ let options = RenderOptions {
+ embedded: true,
+ ..RenderOptions::default()
+ };
+ let mut output = Vec::new();
+ let source = WarningSource::new("html").with_variant("live-recording");
+ let mut warnings = Vec::new();
+ let mut diagnostics = Diagnostics::new(&source, &mut warnings);
+ processor
+ .convert_to_writer(doc, &mut output, &options, &mut diagnostics)
+ .ok()?;
+ for warning in &warnings {
+ eprintln!("live check recording render: {warning}");
+ }
+ String::from_utf8(output).ok()
+}
+
+/// Concatenate every `[time, "o", data]` event's `data` field out of an
+/// asciicast v2 recording, in order, undoing the JSON escaping the checks
+/// worker applies when it writes them — the raw terminal bytes underneath the
+/// recording, for feeding to a *static* terminal renderer (see
+/// [`render_live`]). The finished-recording path doesn't need this: acdc's own
+/// asciicast parser (used by [`render_recording`]) reads the format natively.
+fn extract_output(recording: &str) -> String {
+ let mut out = String::new();
+ for line in recording.lines().skip(1) {
+ if let Some(data) = event_data(line) {
+ out.push_str(&data);
+ }
+ }
+ out
+}
+
+/// Extract and unescape the `data` field of one `[time, "o", "data"]` event
+/// line, or `None` if the line does not look like one.
+fn event_data(line: &str) -> Option<String> {
+ const MARKER: &str = "\"o\", \"";
+ let start = line.find(MARKER)?.checked_add(MARKER.len())?;
+ let rest = line.get(start..)?;
+ let end = rest.rfind("\"]")?;
+ Some(unescape_json_string(rest.get(..end)?))
+}
+
+/// The inverse of the checks worker's hand-rolled JSON string escaping:
+/// unescape `"`, `\`, the recognized single-character escapes, and `\uXXXX`
+/// control-code escapes, passing everything else through unchanged.
+fn unescape_json_string(escaped: &str) -> String {
+ let mut out = String::with_capacity(escaped.len());
+ let mut chars = escaped.chars();
+ while let Some(ch) = chars.next() {
+ if ch != '\\' {
+ out.push(ch);
+ continue;
+ }
+ match chars.next() {
+ Some('"') => out.push('"'),
+ Some('\\') => out.push('\\'),
+ Some('n') => out.push('\n'),
+ Some('r') => out.push('\r'),
+ Some('t') => out.push('\t'),
+ Some('u') => {
+ let hex: String = chars.by_ref().take(4).collect();
+ if let Some(ch) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
+ out.push(ch);
+ }
+ }
+ Some(other) => out.push(other),
+ None => {}
+ }
+ }
+ out
+}
+
/// Render an asciicast v2/v3 `recording` as a replayable terminal session via
/// acdc's `[terminal%replay]` block, or `None` if it cannot be parsed or
/// converted. Wraps `recording` in a listing block, so a recording containing
crates/git-ents-server/src/checks.rs
@@ -26,8 +26,8 @@
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
-use std::sync::Arc;
use std::sync::mpsc::RecvTimeoutError;
+use std::sync::{Arc, Mutex as StdMutex, PoisonError};
use std::time::{Duration, Instant};
use git_ents::checks::{self, Check, RunOutcome, Status};
@@ -38,6 +38,55 @@
/// Where the pushed tree is unpacked inside the Sprite.
const WORKDIR: &str = "/work";
+/// A currently-running check's growing asciicast v2 recording, keyed by the
+/// repository, the commit being checked, and the check's name.
+pub(crate) type LiveKey = (PathBuf, ObjectId, String);
+
+/// Live buffers for every check currently running, shared between the worker
+/// thread appending to a check's output as it arrives and the web layer
+/// polling it for a live view. A buffer exists only while its check is
+/// running — [`live_start`] adds it, [`live_finish`] removes it once the
+/// result is recorded — so a lookup miss unambiguously means "not running"
+/// rather than "running with no output yet". Asciicast is the definitive log
+/// format end to end: the same string a live poll reads is, unmodified,
+/// what [`run_one`] hands back as the check's recorded `recording`.
+pub(crate) type LiveRegistry = Arc<StdMutex<HashMap<LiveKey, Arc<StdMutex<String>>>>>;
+
+/// A fresh, empty [`LiveRegistry`] — one per server process, held on
+/// [`crate::AppState`].
+pub(crate) fn new_live_registry() -> LiveRegistry {
+ Arc::new(StdMutex::new(HashMap::new()))
+}
+
+/// The text accumulated so far for a running check's live buffer, or `None`
+/// when no check is running under `key` (finished, or never started).
+pub(crate) fn live_snapshot(registry: &LiveRegistry, key: &LiveKey) -> Option<String> {
+ let buffer = lock(registry).get(key).cloned()?;
+ Some(lock(&buffer).clone())
+}
+
+/// Register a fresh live buffer for `key`, returning the handle [`run_one`]
+/// appends to as the check's output arrives.
+fn live_start(registry: &LiveRegistry, key: LiveKey) -> Arc<StdMutex<String>> {
+ let buffer = Arc::new(StdMutex::new(String::new()));
+ lock(registry).insert(key, Arc::clone(&buffer));
+ buffer
+}
+
+/// Remove `key`'s live buffer once its check has settled — recorded results
+/// are read from the run ref from then on, not the live registry.
+fn live_finish(registry: &LiveRegistry, key: &LiveKey) {
+ lock(registry).remove(key);
+}
+
+/// Lock a [`StdMutex`], recovering the guard from a poisoned lock rather than
+/// panicking: a live buffer is best-effort output for a browser to look at,
+/// not something worth tearing the process down over if a prior panic
+/// poisoned it.
+fn lock<T>(mutex: &StdMutex<T>) -> std::sync::MutexGuard<'_, T> {
+ mutex.lock().unwrap_or_else(PoisonError::into_inner)
+}
+
/// The environment variable through which the server hands the hook the queue
/// directory; the worker is given the same path directly.
pub const QUEUE_ENV: &str = "GIT_ENTS_CHECKS_QUEUE";
@@ -106,6 +155,7 @@
status,
duration_secs: None,
recording: None,
+ exit_code: None,
})
.collect()
}
@@ -121,7 +171,7 @@
/// every other repository's checks; isolating them by repository keeps a slow
/// repository's backlog from blocking the rest. Jobs for *one* repository stay
/// serialized so concurrent runs never collide in its single Sprite.
-pub async fn worker(queue: PathBuf) {
+pub async fn worker(queue: PathBuf, live: LiveRegistry) {
if let Err(e) = std::fs::create_dir_all(&queue) {
eprintln!("checks: could not create queue directory {queue:?}: {e}");
return;
@@ -138,7 +188,8 @@
continue;
}
let inflight = Arc::clone(&inflight);
- let handle = tokio::task::spawn_blocking(move || drain_repo(&jobs));
+ let live = live.clone();
+ let handle = tokio::task::spawn_blocking(move || drain_repo(&jobs, &live));
tokio::spawn(async move {
let _done = handle.await;
inflight.lock().await.remove(&repo);
@@ -175,9 +226,9 @@
/// Drain one repository's queued jobs in order, deleting each job file after it
/// is handled (whether it ran cleanly or failed) so it is never retried.
-fn drain_repo(jobs: &[(PathBuf, Job)]) {
+fn drain_repo(jobs: &[(PathBuf, Job)], live: &LiveRegistry) {
for (path, job) in jobs {
- if let Err(e) = process_job(job) {
+ if let Err(e) = process_job(job, live) {
eprintln!("checks: {e}");
}
let _removed = std::fs::remove_file(path);
@@ -194,7 +245,7 @@
/// re-validation) finalizes the run as `error` rather than leaving it stuck at
/// `running`, then returns `Err`. Returns `Ok` even when a check fails — a
/// failing check is a recorded result, not an error.
-fn process_job(job: &Job) -> Result<(), String> {
+fn process_job(job: &Job, live: &LiveRegistry) -> Result<(), String> {
let runnable = checks::load(&job.repo).map_err(|e| format!("could not read checks: {e}"))?;
if runnable.is_empty() {
return Ok(());
@@ -249,11 +300,15 @@
let all_pass = deps.iter().all(|status| *status == Status::Pass);
match &check.command {
Some(command) if all_pass => {
- let result = run_one(&sprite, &check.name, command);
+ let key: LiveKey = (job.repo.clone(), job.new, check.name.clone());
+ let buffer = live_start(live, key.clone());
+ let result = run_one(&sprite, &check.name, command, &buffer);
+ live_finish(live, &key);
if let Some(outcome) = outcomes.get_mut(index) {
outcome.status = result.status;
outcome.duration_secs = Some(result.duration_secs);
outcome.recording = Some(result.recording);
+ outcome.exit_code = result.exit_code;
}
}
Some(_) => {
@@ -500,26 +555,34 @@
pixel_height: 0,
};
-/// A finished check run: its outcome, wall-clock duration, and full terminal
-/// session as an asciicast v2 recording.
+/// A finished check run: its outcome, wall-clock duration, process exit code
+/// (when the command ran to completion), and the full terminal session as an
+/// asciicast v2 recording.
struct RunResult {
status: Status,
duration_secs: u64,
recording: String,
+ exit_code: Option<i32>,
}
/// Run one check in the Sprite's [`WORKDIR`], recording its terminal session —
/// a real pty (`sprite exec --tty`), not a pipe, so the recording plays back
/// exactly what a developer running the check by hand would see — and logging
-/// a `PASS`/`FAIL` line. Returns its outcome; a check that exceeds
-/// [`CHECK_TIMEOUT`] or cannot be captured is [`Status::Error`].
-fn run_one(sprite: &str, name: &str, command: &str) -> RunResult {
+/// a `PASS`/`FAIL` line. `live` is appended to as output arrives, in the same
+/// asciicast v2 format as the final recording, so a browser can poll it for a
+/// live view of a check still in progress; it is what [`finish`] hands back
+/// as the recorded `recording`, not a separate representation of the same
+/// output. Returns the check's outcome; a check that exceeds [`CHECK_TIMEOUT`]
+/// or cannot be captured is [`Status::Error`].
+fn run_one(sprite: &str, name: &str, command: &str, live: &Arc<StdMutex<String>>) -> RunResult {
let start = Instant::now();
+ lock(live).push_str(&asciicast_header());
+
let pair = match native_pty_system().openpty(CHECK_PTY_SIZE) {
Ok(pair) => pair,
Err(e) => {
eprintln!("checks: ERROR {name} (could not allocate a pty: {e})");
- return finish(Status::Error, start, &[]);
+ return finish(Status::Error, start, None, live);
}
};
let mut cmd = CommandBuilder::new("sprite");
@@ -530,7 +593,7 @@
Ok(child) => child,
Err(e) => {
eprintln!("checks: ERROR {name} (could not run: {e})");
- return finish(Status::Error, start, &[]);
+ return finish(Status::Error, start, None, live);
}
};
// The child holds the slave now; drop ours so the master sees EOF when the
@@ -541,7 +604,7 @@
let Ok(mut reader) = master.try_clone_reader() else {
eprintln!("checks: ERROR {name} (could not read the pty)");
let _killed = child.kill();
- return finish(Status::Error, start, &[]);
+ return finish(Status::Error, start, None, live);
};
// The pty's `Read` is blocking, so it gets its own thread; the main thread
@@ -563,17 +626,17 @@
}
});
- let mut events: Vec<(f64, String)> = Vec::new();
let deadline = start.checked_add(CHECK_TIMEOUT).unwrap_or(start);
let timed_out = loop {
let Some(remaining) = deadline.checked_duration_since(Instant::now()) else {
break true;
};
match rx.recv_timeout(remaining) {
- Ok(chunk) => events.push((
- start.elapsed().as_secs_f64(),
- String::from_utf8_lossy(&chunk).into_owned(),
- )),
+ Ok(chunk) => {
+ let elapsed = start.elapsed().as_secs_f64();
+ let data = String::from_utf8_lossy(&chunk);
+ push_event(&mut lock(live), elapsed, &data);
+ }
Err(RecvTimeoutError::Timeout) => break true,
Err(RecvTimeoutError::Disconnected) => break false,
}
@@ -583,55 +646,63 @@
if timed_out {
eprintln!("checks: ERROR {name} (timed out after {CHECK_TIMEOUT:?})");
let _killed = child.kill();
- return finish(Status::Error, start, &events);
+ return finish(Status::Error, start, None, live);
}
let status = match child.wait() {
Ok(status) => status,
Err(e) => {
eprintln!("checks: ERROR {name} (could not wait on the sprite CLI: {e})");
- return finish(Status::Error, start, &events);
+ return finish(Status::Error, start, None, live);
}
};
+ let exit_code = Some(i32::try_from(status.exit_code()).unwrap_or(i32::MAX));
if status.success() {
eprintln!("checks: PASS {name}");
- finish(Status::Pass, start, &events)
+ finish(Status::Pass, start, exit_code, live)
} else {
eprintln!("checks: FAIL {name} ({command})");
- finish(Status::Fail, start, &events)
+ finish(Status::Fail, start, exit_code, live)
}
}
-/// Assemble a [`RunResult`] from `events` captured so far — used on every exit
-/// path, including the failure ones, so a check that errors out still keeps
-/// whatever terminal output it produced before that happened.
-fn finish(status: Status, start: Instant, events: &[(f64, String)]) -> RunResult {
+/// Assemble a [`RunResult`] from `live`'s accumulated recording — used on
+/// every exit path, including the failure ones, so a check that errors out
+/// still keeps whatever terminal output it produced before that happened.
+fn finish(
+ status: Status,
+ start: Instant,
+ exit_code: Option<i32>,
+ live: &StdMutex<String>,
+) -> RunResult {
RunResult {
status,
duration_secs: start.elapsed().as_secs(),
- recording: asciicast(events),
+ recording: lock(live).clone(),
+ exit_code,
}
}
-/// Render `events` (elapsed-seconds, output chunk) pairs captured from a
-/// check's pty as an asciicast v2 recording: a header line naming the
-/// terminal's fixed [`CHECK_PTY_SIZE`], then one `[time, "o", data]` output
-/// event per line — the format the Checks tab's `asciinema-player` replay
-/// expects (<https://docs.asciinema.org/manual/asciicast/v2/>).
-fn asciicast(events: &[(f64, String)]) -> String {
- let mut out = format!(
+/// The asciicast v2 header line naming the terminal's fixed [`CHECK_PTY_SIZE`]
+/// — the first line of every check recording, live or finished (see
+/// <https://docs.asciinema.org/manual/asciicast/v2/>).
+fn asciicast_header() -> String {
+ format!(
"{{\"version\": 2, \"width\": {}, \"height\": {}}}\n",
CHECK_PTY_SIZE.cols, CHECK_PTY_SIZE.rows
- );
- for (time, data) in events {
- out.push('[');
- out.push_str(&format!("{time:.6}"));
- out.push_str(", \"o\", ");
- push_json_string(data, &mut out);
- out.push_str("]\n");
- }
- out
+ )
+}
+
+/// Append one asciicast v2 `[time, "o", data]` output event to `out`, the
+/// Checks tab's replay format for a chunk of pty output captured `time`
+/// seconds into the run.
+fn push_event(out: &mut String, time: f64, data: &str) {
+ out.push('[');
+ out.push_str(&format!("{time:.6}"));
+ out.push_str(", \"o\", ");
+ push_json_string(data, out);
+ out.push_str("]\n");
}
/// Append `value` to `out` as a quoted JSON string. Hand-rolled rather than
crates/git-ents-server/src/http.rs
@@ -575,6 +575,7 @@
sessions: crate::web::new_sessions(),
challenges: crate::web::new_challenges(),
web_signing_key: None,
+ live_runs: crate::checks::new_live_registry(),
}
}
crates/git-ents-server/src/main.rs
@@ -98,6 +98,9 @@
/// The server's own signing key for browser-made edits; `None` disables
/// editing.
pub(crate) web_signing_key: Option<PathBuf>,
+ /// Live output for checks the worker currently has running, polled by the
+ /// Checks tab's live view.
+ pub(crate) live_runs: checks::LiveRegistry,
}
fn main() -> ExitCode {
@@ -152,10 +155,14 @@
sessions: web::new_sessions(),
challenges: web::new_challenges(),
web_signing_key: args.web_signing_key,
+ live_runs: checks::new_live_registry(),
};
// Drain queued pushes and run their checks for the life of the server.
- tokio::spawn(checks::worker(state.checks_queue.clone()));
+ tokio::spawn(checks::worker(
+ state.checks_queue.clone(),
+ state.live_runs.clone(),
+ ));
// The git smart-HTTP protocol streams whole packfiles through the request
// body, so the default 2 MiB cap would reject any non-trivial push.
crates/git-ents/src/checks.rs
@@ -225,6 +225,10 @@
/// The check's terminal session, captured as asciicast v2 (JSONL) text,
/// when the runner recorded one.
recording: Option<String>,
+ /// The command's process exit code, when the check ran to completion
+ /// rather than erroring out before or during execution (an unreachable
+ /// sandbox, a timeout).
+ exit_code: Option<i32>,
}
/// One check's outcome within a [`Run`], assembled from its map key and
@@ -240,6 +244,10 @@
/// The check's terminal session, captured as asciicast v2 (JSONL) text,
/// when the runner recorded one.
pub recording: Option<String>,
+ /// The command's process exit code, when the check ran to completion
+ /// rather than erroring out before or during execution (an unreachable
+ /// sandbox, a timeout).
+ pub exit_code: Option<i32>,
}
/// One recorded execution of the check set against a commit.
@@ -341,6 +349,7 @@
status: outcome.status,
duration_secs: outcome.duration_secs,
recording: outcome.recording.clone(),
+ exit_code: outcome.exit_code,
},
)
}
@@ -352,6 +361,7 @@
status: outcome.status,
duration_secs: outcome.duration_secs,
recording: outcome.recording,
+ exit_code: outcome.exit_code,
}
}
@@ -483,6 +493,7 @@
status,
duration_secs: None,
recording: None,
+ exit_code: None,
}
}
@@ -545,6 +556,7 @@
status: Status::Pass,
duration_secs: Some(12),
recording: Some("{\"version\": 2}\n[0.5, \"o\", \"hi\\r\\n\"]\n".to_owned()),
+ exit_code: Some(0),
};
record(&repo, commit, std::slice::from_ref(&rich)).unwrap();
let commits = runs(&repo).unwrap();
crates/git-ents-server/src/web/assets.rs
@@ -11,3 +11,7 @@
/// Clipboard handler for the clone-URL copy button.
pub(super) const COPY_SCRIPT: &str = include_str!("copy.js");
+
+/// Polls a running check's live-output fragment and swaps it in, reloading the
+/// page once the server reports the check has finished.
+pub(super) const LIVE_SCRIPT: &str = include_str!("live.js");
crates/git-ents-server/src/web/mod.rs
@@ -42,7 +42,7 @@
csrf: String,
}
-use self::assets::{COPY_SCRIPT, FONTS, STYLE};
+use self::assets::{COPY_SCRIPT, FONTS, LIVE_SCRIPT, STYLE};
use self::git::{discover_repos, git_output};
use self::icons::{icon_branch, icon_chevron, icon_folder, icon_logo, icon_repo, icon_search};
@@ -77,7 +77,16 @@
}
if let Some((repo, rel, rest)) = resolve_repo(&state.data_dir, &segments) {
- return route(&repo, &rel, rest, host, session, editing_enabled(state)).await;
+ return route(
+ &repo,
+ &rel,
+ rest,
+ host,
+ session,
+ editing_enabled(state),
+ &state.live_runs,
+ )
+ .await;
}
not_found().into_response()
@@ -375,6 +384,7 @@
host: Option<&str>,
session: Option<write::SessionSnapshot>,
editing: bool,
+ live_runs: &crate::checks::LiveRegistry,
) -> Response {
let meta = gather_meta(repo, rel).await;
match rest.split_first() {
@@ -412,7 +422,10 @@
Some((&"releases", &[])) => pages::releases_page(repo, &meta).await.into_response(),
Some((&"checks", &[])) => pages::checks_page(repo, &meta).await.into_response(),
Some((&"checks", &[commit, name])) => {
- pages::check_recording_page(repo, &meta, commit, name).await
+ pages::check_recording_page(repo, &meta, commit, name, live_runs).await
+ }
+ Some((&"checks", &[commit, name, "live"])) => {
+ pages::check_live_fragment(repo, commit, name, live_runs).await
}
Some((&"issues", &[])) => pages::issues_page(repo, &meta).await.into_response(),
Some((&"settings", &[])) => {
@@ -762,6 +775,7 @@
}
}
script { (PreEscaped(COPY_SCRIPT)) }
+ script { (PreEscaped(LIVE_SCRIPT)) }
}
}
}
crates/git-ents-server/src/web/pages.rs
@@ -960,7 +960,7 @@
code.key { (check.name) }
@match outcome {
None => span.muted { "no run yet" }
- Some(outcome) if outcome.recording.is_some() => {
+ Some(outcome) if outcome.recording.is_some() || is_in_progress(outcome.status) => {
a href={ "/" (rel) "/checks/" (head) "/" (check.name) } { (outcome.status.to_string()) }
}
Some(outcome) => span.muted { (outcome.status.to_string()) }
@@ -969,45 +969,79 @@
}
}
-/// One check's recorded terminal session on `commit`, replayed with
-/// `asciinema-player` — reached by clicking a linked status on the "Checks on
-/// HEAD" card. 404s when `commit` has no run recorded, `name` is not among its
-/// results, or that outcome carries no recording (an older run, from before
-/// recording landed, or a check that errored before a pty was allocated).
+/// Whether `status` is still on its way to a terminal outcome — the check has
+/// no recording yet, but its run page has a live view worth linking to.
+fn is_in_progress(status: git_ents::checks::Status) -> bool {
+ matches!(
+ status,
+ git_ents::checks::Status::Queued | git_ents::checks::Status::Running
+ )
+}
+
+/// Find `name`'s outcome in `commit`'s latest recorded run, or `None` when
+/// `commit` has no run, or no result under that name.
+async fn latest_outcome(
+ repo: &Path,
+ commit_oid: ObjectId,
+ name: &str,
+) -> Option<git_ents::checks::RunOutcome> {
+ load_runs(repo)
+ .await
+ .ok()?
+ .into_iter()
+ .find(|commit_runs| commit_runs.commit == commit_oid)
+ .and_then(|commit_runs| commit_runs.runs.into_iter().next())
+ .and_then(|run| run.results.into_iter().find(|result| result.name == name))
+}
+
+/// One check's terminal session on `commit` — reached by clicking a linked
+/// status on the "Checks on HEAD" card. While the check is still `queued` or
+/// `running` this is a live view, polling [`check_live_fragment`] until the
+/// check settles; once it has, it replays the finished recording with
+/// `asciinema-player`, or reports the exit code plain when there was no
+/// output to replay. 404s when `commit` has no run recorded or `name` is not
+/// among its results.
pub(super) async fn check_recording_page(
repo: &Path,
meta: &RepoMeta,
commit: &str,
name: &str,
+ live_runs: &crate::checks::LiveRegistry,
) -> Response {
let Some(commit_oid) = ObjectId::from_hex(commit.as_bytes()).ok() else {
return not_found().into_response();
};
- let runs = load_runs(repo).await;
- let recording = runs.ok().and_then(|commits| {
- commits
- .into_iter()
- .find(|commit_runs| commit_runs.commit == commit_oid)
- .and_then(|commit_runs| commit_runs.runs.into_iter().next())
- .and_then(|run| run.results.into_iter().find(|result| result.name == name))
- .and_then(|outcome| outcome.recording)
- });
- let Some(recording) = recording else {
+ let Some(outcome) = latest_outcome(repo, commit_oid, name).await else {
return not_found().into_response();
};
let short_commit = commit.get(..8).unwrap_or(commit);
- let body = if crate::asciidoc::recording_has_no_output(&recording) {
+ let rel = &meta.rel;
+
+ let body = if is_in_progress(outcome.status) {
+ let key = (repo.to_owned(), commit_oid, name.to_owned());
+ let fragment_url = format!("/{rel}/checks/{commit}/{name}/live");
+ let initial = live_fragment_body(crate::checks::live_snapshot(live_runs, &key));
html! {
- p.muted { "This check produced no terminal output." }
+ p.shell-note {
+ "This check is still " (outcome.status.to_string()) "; the view below updates live."
+ }
+ style { (PreEscaped(crate::asciidoc::TERMINAL_VIEW_CSS)) }
+ div #live-terminal data-live-check=(fragment_url) { (initial) }
+ }
+ } else if let Some(recording) = &outcome.recording {
+ if crate::asciidoc::recording_has_no_output(recording) {
+ html! { (no_output_notice(&outcome)) }
+ } else {
+ let Some(player) = crate::asciidoc::render_recording(recording) else {
+ return not_found().into_response();
+ };
+ html! {
+ style { (PreEscaped(crate::asciidoc::TERMINAL_VIEW_CSS)) }
+ (PreEscaped(player))
+ }
}
} else {
- let Some(player) = crate::asciidoc::render_recording(&recording) else {
- return not_found().into_response();
- };
- html! {
- style { (PreEscaped(crate::asciidoc::TERMINAL_VIEW_CSS)) }
- (PreEscaped(player))
- }
+ return not_found().into_response();
};
repo_shell(
meta,
@@ -1023,6 +1057,58 @@
.into_response()
}
+/// The best-possible-UX fallback for a settled check that produced no
+/// terminal output: its exit code when the command actually ran, or just its
+/// status when it didn't (a composite, or an infra failure before any command
+/// started).
+fn no_output_notice(outcome: &git_ents::checks::RunOutcome) -> Markup {
+ html! {
+ @match outcome.exit_code {
+ Some(code) => p.muted { "Check finished with exit code " code { (code) } " without output." }
+ None => p.muted { "This check produced no terminal output." }
+ }
+ }
+}
+
+/// The live-terminal container's inner markup for one poll: the check's
+/// current screen, rendered as a static snapshot (see
+/// [`asciidoc::render_live`](crate::asciidoc::render_live)), or a placeholder
+/// while output has yet to arrive.
+fn live_fragment_body(recording: Option<String>) -> Markup {
+ let rendered = recording
+ .filter(|recording| !crate::asciidoc::recording_has_no_output(recording))
+ .and_then(|recording| crate::asciidoc::render_live(&recording));
+ match rendered {
+ Some(player) => html! { (PreEscaped(player)) },
+ None => html! { p.muted { "Waiting for output…" } },
+ }
+}
+
+/// One poll of a running check's live output — the fragment [`LIVE_SCRIPT`]
+/// swaps into the run page's `#live-terminal` container. Signals completion
+/// (the check no longer has a live buffer: it settled, or was never queued)
+/// via the `X-Check-Live: done` response header rather than the body, so the
+/// script can tell a finished check apart from one that simply has no output
+/// yet.
+///
+/// [`LIVE_SCRIPT`]: super::assets::LIVE_SCRIPT
+pub(super) async fn check_live_fragment(
+ repo: &Path,
+ commit: &str,
+ name: &str,
+ live_runs: &crate::checks::LiveRegistry,
+) -> Response {
+ let Some(commit_oid) = ObjectId::from_hex(commit.as_bytes()).ok() else {
+ return not_found().into_response();
+ };
+ let key = (repo.to_owned(), commit_oid, name.to_owned());
+ let recording = crate::checks::live_snapshot(live_runs, &key);
+ let done = recording.is_none();
+ let body = live_fragment_body(recording).into_string();
+ let header = if done { "done" } else { "running" };
+ ([("x-check-live", header)], body).into_response()
+}
+
/// Load the configured check set off the async runtime, since `checks::load`
/// shells out to git and reads the object database synchronously.
async fn load_checks(repo: &Path) -> Result<Vec<git_ents::checks::Check>, String> {
crates/git-ents-server/src/web/live.js
@@ -1,0 +1,20 @@
+document.querySelectorAll('[data-live-check]').forEach((container) => {
+ const url = container.dataset.liveCheck;
+ const poll = () => {
+ fetch(url, { cache: 'no-store' })
+ .then((response) => {
+ if (response.headers.get('X-Check-Live') === 'done') {
+ window.location.reload();
+ return null;
+ }
+ return response.text();
+ })
+ .then((html) => {
+ if (html === null) return;
+ container.innerHTML = html;
+ setTimeout(poll, 1000);
+ })
+ .catch(() => setTimeout(poll, 2000));
+ };
+ poll();
+});