git-ents.gitmain
⌘K
foforge
commit 622810a
feat: 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

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

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(); +});