use super::escape::esc;
use super::fleet::fleet_sections_by_anchor;
use super::layout::wrap_page;
use super::proc::{cast_embed_html, proc_elapsed_phrase, proc_has_cast, proc_meta_html, summary_stats_html};
use super::workflow::{proc_task_anchor_html, proc_task_attrs, workflow_graph_html};
use crate::daemon::model::{ProcStatus, Session, SessionLifecycle, Store};
use crate::daemon::paths::now_unix_secs;
pub fn session_page(store: &Store, session_id: &str) -> Option<String> {
Some(session_page_for(store.sessions.get(session_id)?, store.port))
}
pub fn session_page_for(session: &Session, port: u16) -> String {
let now = now_unix_secs();
let mut procs_html = String::new();
let mut fleet_sections = fleet_sections_by_anchor(session);
for proc in &session.procs {
let detail = proc.detail.as_deref().unwrap_or("");
let elapsed = proc_elapsed_phrase(proc, now);
let terminating = matches!(
proc.fail_reason.as_deref(),
Some(crate::failure::reason::STOP_REQUESTED) | Some(crate::failure::reason::RESTART_REQUESTED)
);
let finished = !matches!(proc.status, ProcStatus::Running | ProcStatus::Waiting);
let note = if finished && !detail.is_empty() { detail } else { proc.note.as_deref().unwrap_or("") };
let note_html =
if finished && looks_like_artifact_path(note) { format!("<code>{}</code>", esc(note)) } else { esc(note) };
let body_html = if proc_has_cast(proc) { cast_embed_html(&session.id, proc) } else { String::new() };
let snapshot_btn = proc_snapshot_btn_html(&session.id, proc);
let diff_btn = proc_diff_btn_html(&session.id, proc);
let annotation_target = annotation_target_link_html(session, proc);
let attempt_chip = attempt_chip_html(session, proc);
let retry_link = retry_link_html(session, proc);
let original_link = original_attempt_link_html(session, proc);
let task_anchor = proc_task_anchor_html(session, proc);
procs_html.push_str(&format!(
r#"<details class="chamfer proc {status_class}" id="proc-{index}" data-index="{index}"{task_attrs}>
<div class="proc-actions">{diff_btn}{snapshot_btn}{restart_btn}{kill_btn}</div>
<summary>
{task_anchor}
<span class="triangle" aria-hidden="true"></span>
<span class="label">{label}</span>{attempt_chip} {proc_stat}
<span class="meta" data-proc-elapsed="{index}">{elapsed}</span>{retry_link}{original_link}
<span class="note dim">{note}</span>
{annotation_target}
</summary>
{proc_meta}
<div class="detail">{detail}</div>
{container_line}
{body_html}
</details>
"#,
status_class = if terminating { "terminating" } else { proc.status.as_str() },
index = proc.index,
task_attrs = proc_task_attrs(session, proc),
task_anchor = task_anchor,
label = esc(&proc.label),
proc_stat = summary_stats_html(proc, now),
diff_btn = diff_btn,
annotation_target = annotation_target,
snapshot_btn = snapshot_btn,
restart_btn = proc_restart_btn_html(session, now, proc),
kill_btn = proc_kill_btn_html(session, now, proc),
proc_meta = proc_meta_html(proc),
elapsed = esc(&elapsed),
note = note_html,
detail = esc(detail),
container_line = container_line_html(proc),
attempt_chip = attempt_chip,
original_link = original_link,
retry_link = retry_link,
body_html = body_html
));
if let Some(fleets) = fleet_sections.remove(&proc.index) {
procs_html.push_str(&fleets);
}
}
let id = esc(&session.id);
let session_meta = session_meta_html(session, now);
let lifecycle = session.lifecycle_status(now);
let pending = chapters_pending_count(session);
let label = session_export_label(lifecycle, pending);
let export_btn = format!(
"<a class=\"chamfer btn btn--cyan btn--sm session-export\" href=\"/job/{id}/export.html\" download title=\"Offline HTML snapshot of this entire job\"><span>{label}</span></a>\n",
id = id,
label = label,
);
let restartable = session.profile.is_some()
&& session.parent_session.is_none()
&& session.repo != crate::daemon::server::IMAGE_BUILDS_REPO
&& session.repo != crate::daemon::server::INTERNAL_REPO;
let stop_btn = if lifecycle == SessionLifecycle::Running {
let restart = if restartable {
format!(
"<button type=\"button\" class=\"chamfer btn btn--orange btn--sm\" id=\"session-restart\" data-session=\"{id}\" title=\"Force-restart this job? The current run is stopped (containers killed) and the same job starts fresh.\"><span>Force restart</span></button>\n",
id = id,
)
} else {
String::new()
};
format!(
"{restart}<button type=\"button\" class=\"chamfer btn btn--red btn--sm\" id=\"session-stop\" data-session=\"{id}\" title=\"Force-stop this job? Running containers will be killed.\"><span>Force stop</span></button>\n",
id = id,
)
} else if restartable && matches!(lifecycle, SessionLifecycle::Failed | SessionLifecycle::Cancelled) {
let resume = if session.kind.as_deref() == Some("workflow") {
format!(
"<button type=\"button\" class=\"chamfer btn btn--orange btn--sm\" id=\"session-resume\" data-session=\"{id}\" title=\"Restart this job, reusing every completed step's result from this run — only the steps that never completed are run again.\"><span>Restart remaining</span></button>\n",
id = id,
)
} else {
String::new()
};
format!(
"{resume}<button type=\"button\" class=\"chamfer btn btn--orange btn--sm\" id=\"session-restart-scratch\" data-session=\"{id}\" title=\"Start the same job again from scratch — every step runs anew.\"><span>Restart from scratch</span></button>\n",
id = id,
)
} else {
String::new()
};
let workflow = workflow_graph_html(session, now);
let lede = session_lede_html(session, lifecycle);
let chapters_pending = chapters_pending_html(pending);
let job_diff_btn = if session.procs.iter().any(|proc| proc.diff_path.is_some()) {
format!(
"<a class=\"chamfer btn btn--purple btn--sm job-diff\" data-job-diff href=\"/diff/{}/all\" title=\"Browse the entire end-to-end commits diff\"><span>⇄ all commits</span></a>",
esc(&session.id)
)
} else {
String::new()
};
let body = format!(
"<div class=\"chamfer card card--accent-left-purple\"><div class=\"session-actions\">{job_diff_btn}{export_btn}{stop_btn}</div>\
{session_meta}\n{chapters_pending}</div>\n\
{workflow}<div class=\"procs\" id=\"session-procs\">\n{procs}</div>",
export_btn = export_btn,
job_diff_btn = job_diff_btn,
stop_btn = stop_btn,
session_meta = session_meta,
chapters_pending = chapters_pending,
workflow = workflow,
procs = procs_html,
);
wrap_page(&format!("job {}", session.id), port, Some(&session.id), None, &lede, &body)
}
fn container_runtime_name(runtime: Option<&str>) -> &'static str {
match runtime {
Some("container") => "Apple Containers",
Some("docker") => "Docker",
Some("podman") => "Podman",
Some(_) => "Other runtime",
None => "Not recorded (legacy run)",
}
}
fn container_line_html(proc: &crate::daemon::model::ProcRecord) -> String {
let Some(container) = proc.container_name.as_deref() else {
return String::new();
};
format!(
"<div class=\"container dim\"><span class=\"container-runtime-label\">runtime</span> \
<span class=\"container-runtime-name\">{runtime}</span> · container: {container}</div>\n",
runtime = container_runtime_name(proc.container_runtime.as_deref()),
container = esc(container),
)
}
fn annotation_target_link_html(
session: &crate::daemon::model::Session, proc: &crate::daemon::model::ProcRecord,
) -> String {
if proc.kind != crate::daemon::model::ProcKind::Annotate {
return String::new();
}
let Some(target) = proc.annotate_target.as_deref() else {
return String::new();
};
let target_name = std::path::Path::new(target).file_name().and_then(|s| s.to_str()).unwrap_or("source recording");
let href = session
.procs
.iter()
.find(|candidate| candidate.cast_path.as_deref() == Some(target))
.map(|candidate| format!("/job/{}#proc-{}", esc(&session.id), candidate.index))
.or_else(|| session.parent_session.as_ref().map(|parent| format!("/job/{}", esc(parent))))
.unwrap_or_else(|| format!("/job/{}", esc(&session.id)));
format!(
"<a class=\"annotation-target\" href=\"{href}\" title=\"Recording being annotated: {target}\">↩ source run</a>",
target = esc(target_name)
)
}
fn supervisor_meta_html(session: &Session, now: u64) -> String {
let sup = &session.supervisor;
let attempt = sup.attempt();
if attempt == 1 && sup.next_retry_at.is_none() && sup.gave_up.is_none() && sup.restarted_as.is_none() {
return String::new();
}
let (restarts, state) = if let Some(why) = &sup.gave_up {
(attempt.saturating_sub(1), format!("gave up — {}", esc(why)))
} else if let Some(new_id) = &sup.restarted_as {
(attempt, format!("continued as <a href=\"/job/{id}\"><code class=\"job-id\">{id}</code></a>", id = esc(new_id)))
} else if let Some(at) = sup.next_retry_at {
let wait = at.saturating_sub(now);
(attempt, format!("scheduled in {}", esc(&super::format::format_duration_secs(wait))))
} else {
(attempt.saturating_sub(1), "running replacement".to_string())
};
format!(
"<dt data-session-supervisor-label>Job restarts</dt><dd data-session-supervisor>{restarts} of {max} · {state}</dd>\n",
max = sup.retries,
)
}
pub(crate) fn session_lede_html(session: &Session, lifecycle: SessionLifecycle) -> String {
let kind = session.kind.as_deref().unwrap_or("profile");
let profile = session.profile.as_deref().unwrap_or("default");
let n = session.procs.len().max(session.skills.len());
format!(
"{kind} <strong>{profile}</strong> · {life} · {n} task{plural}",
kind = esc(kind),
profile = esc(profile),
life = esc(lifecycle.label()),
n = n,
plural = if n == 1 { "" } else { "s" },
)
}
pub(crate) fn session_ended_text(session: &Session, lifecycle: SessionLifecycle) -> String {
match (session.ended_at, lifecycle) {
(Some(t), _) => format!("{} UTC", crate::runtime::format_utc_timestamp(t)),
(None, SessionLifecycle::Running) => "still running".into(),
(None, SessionLifecycle::Failed) => {
format!("{} UTC", crate::runtime::format_utc_timestamp(session.liveness_deadline()))
}
(None, _) => "—".into(),
}
}
pub(crate) fn chapters_pending_count(session: &Session) -> usize {
use crate::daemon::model::ProcKind;
session
.procs
.iter()
.filter(|p| {
if p.kind == ProcKind::Annotate {
return false;
}
let Some(cast) = p.cast_path.as_deref() else {
return false;
};
match crate::daemon::chapters_sidecar_path(cast) {
Some(path) => !path.exists(),
None => false,
}
})
.count()
}
fn session_export_label(lifecycle: SessionLifecycle, pending: usize) -> &'static str {
if lifecycle == SessionLifecycle::Running {
"Incomplete job ⬇"
} else if pending > 0 {
"Chapters pending ⬇"
} else {
"Job snapshot ⬇"
}
}
fn chapters_pending_html(pending: usize) -> String {
if pending == 0 {
return String::new();
}
format!(
r#"<p class="chapters-pending dim" id="chapters-pending" data-pending="{n}">{n} cast{plural} finalizing chapters</p>"#,
n = pending,
plural = if pending == 1 { "" } else { "s" },
)
}
pub(crate) fn attempt_chip_html(session: &Session, proc: &crate::daemon::model::ProcRecord) -> String {
let (attempt, _) = session.proc_attempt(proc);
if attempt <= 1 {
return String::new();
}
format!(r#" <span class="chamfer agent-badge attempt-chip"><span>attempt {attempt}</span></span>"#)
}
pub(crate) fn retry_link_html(session: &Session, proc: &crate::daemon::model::ProcRecord) -> String {
if proc.fail_reason.as_deref() == Some(crate::failure::reason::RESTART_REQUESTED) {
return r#" <span class="proc-retry-pending">replacement starting…</span>"#.into();
}
if proc.status != ProcStatus::Fail {
return String::new();
}
let Some(next) = session.proc_next_attempt(proc) else {
return String::new();
};
format!(
r##" <a class="proc-retry-link" href="#proc-{idx}" title="This attempt failed and was retried; the newest attempt is authoritative">superseded — see attempt {ord} ↓</a>"##,
idx = next.index,
ord = session.proc_attempt(next).0,
)
}
pub(crate) fn original_attempt_link_html(session: &Session, proc: &crate::daemon::model::ProcRecord) -> String {
let original = session.proc_first_attempt(proc);
if original.index == proc.index {
return String::new();
}
format!(
r##" <a class="proc-original-link" href="#proc-{idx}" title="Jump to the original run in this attempt chain">original attempt ↑</a>"##,
idx = original.index,
)
}
fn looks_like_artifact_path(text: &str) -> bool {
!text.is_empty()
&& (text.starts_with('/') || text.starts_with("tmp/") || text.starts_with(".harness/"))
&& !text.contains(char::is_whitespace)
}
fn proc_diff_btn_html(session_id: &str, proc: &crate::daemon::model::ProcRecord) -> String {
if proc.diff_path.is_none() {
return String::new();
}
format!(
"<a class=\"chamfer btn btn--purple btn--sm proc-diff\" data-proc-diff href=\"/diff/{id}/{index}\" title=\"Browse the commits this step brought into your branch — one self-contained review page\"><span>⇄ commits diff</span></a>",
index = proc.index,
id = esc(session_id),
)
}
fn proc_snapshot_btn_html(session_id: &str, proc: &crate::daemon::model::ProcRecord) -> String {
if !proc_has_cast(proc) {
return String::new();
}
let live = matches!(proc.status, ProcStatus::Running | ProcStatus::Waiting);
let label = if live { "Incomplete run ⬇" } else { "Run snapshot ⬇" };
format!(
r#"<a class="chamfer btn btn--cyan btn--sm proc-snapshot" href="/cast/{sid}/{idx}/export.html" data-cast-export download hidden title="Offline HTML snapshot of this run"><span>{label}</span></a>"#,
sid = esc(session_id),
idx = proc.index,
label = label,
)
}
fn proc_accepts_kill(session: &Session, now: u64, proc: &crate::daemon::model::ProcRecord) -> bool {
use crate::daemon::model::{ProcStatus, SessionLifecycle};
session.lifecycle_status(now) == SessionLifecycle::Running
&& matches!(proc.status, ProcStatus::Running | ProcStatus::Waiting)
&& !matches!(
proc.fail_reason.as_deref(),
Some(crate::failure::reason::STOP_REQUESTED) | Some(crate::failure::reason::RESTART_REQUESTED)
)
}
fn proc_restart_btn_html(session: &Session, now: u64, proc: &crate::daemon::model::ProcRecord) -> String {
use crate::daemon::model::ProcKind;
if proc.kind != ProcKind::Skill || !proc_accepts_kill(session, now, proc) {
return String::new();
}
format!(
"<button type=\"button\" class=\"chamfer btn btn--orange btn--sm proc-restart\" data-proc-restart=\"{index}\" data-session=\"{id}\" title=\"Force-restart this run only — the container is killed and a fresh attempt of the same route starts; the rest of the job continues\"><span>Force restart</span></button>",
index = proc.index,
id = esc(&session.id),
)
}
fn proc_kill_btn_html(session: &Session, now: u64, proc: &crate::daemon::model::ProcRecord) -> String {
use crate::daemon::model::ProcKind;
if !proc_accepts_kill(session, now, proc) {
return String::new();
}
format!(
"<button type=\"button\" class=\"chamfer btn btn--red btn--sm proc-kill\" data-proc-stop=\"{index}\" data-proc-kind=\"{}\" data-session=\"{id}\" title=\"{}\"><span>{}</span></button>",
proc.kind.as_str(),
if proc.kind == ProcKind::Annotate {
"Stop this annotation — the recording remains unchanged"
} else {
"Force-stop this container only — the rest of the job continues"
},
if proc.kind == ProcKind::Annotate { "Stop annotation" } else { "Force stop" },
index = proc.index,
id = esc(&session.id),
)
}
fn session_meta_html(session: &Session, now: u64) -> String {
use super::format::format_duration_secs;
let lifecycle = session.lifecycle_status(now);
let started = format!("{} UTC", crate::runtime::format_utc_timestamp(session.started_at));
let ended = session_ended_text(session, lifecycle);
let duration = session.duration_secs(now).map(format_duration_secs).unwrap_or_else(|| "—".into());
let last_seen = session.last_seen_at;
format!(
r#"<dl class="session-meta" id="session-meta"
data-started="{started_at}" data-ended="{ended_at}" data-last-seen="{last_seen}"
data-lifecycle="{lifecycle_class}" data-lifecycle-label="{lifecycle_label}"
data-repo="{repo}" data-branch="{branch}">
<dt>Started</dt><dd data-session-started>{started}</dd>
<dt>Ended</dt><dd data-session-ended>{ended}</dd>
<dt>Duration</dt><dd data-session-duration>{duration}</dd>
<dt>Repo</dt><dd data-session-repo><code class="repo-path">{repo}</code></dd>
<dt>Branch</dt><dd data-session-branch><code>{branch}</code></dd>
{supervisor}</dl>"#,
supervisor = supervisor_meta_html(session, now),
started_at = session.started_at,
ended_at = session.ended_at.map(|t| t.to_string()).unwrap_or_default(),
last_seen = last_seen,
lifecycle_class = lifecycle.css_class(),
lifecycle_label = lifecycle.label(),
repo = esc(&session.repo),
branch = esc(&session.branch),
started = esc(&started),
ended = esc(&ended),
duration = esc(&duration),
)
}