use wsb::prelude::*;
use axum::{
extract::{Path as AxumPath, State},
http::StatusCode,
response::sse::{Event, KeepAlive, Sse},
Json,
};
use chrono::{DateTime, Utc};
use futures::stream::Stream;
use std::collections::{HashMap, VecDeque};
use std::convert::Infallible;
use tokio::sync::{broadcast, RwLock};
use uuid::Uuid;
use super::state::WebState;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OpKind {
GitScan,
GitMutation,
FileOperation,
CatalogueScan,
Command,
}
impl OpKind {
pub fn recurs(self) -> bool {
match self {
OpKind::GitScan | OpKind::CatalogueScan => true,
OpKind::GitMutation | OpKind::FileOperation | OpKind::Command => false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OpStatus {
Running,
Completed,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Op {
pub id: Uuid,
pub kind: OpKind,
pub label: String,
pub status: OpStatus,
pub progress: f32,
pub error: Option<String>,
pub started_at: DateTime<Utc>,
pub ended_at: Option<DateTime<Utc>>,
pub has_output: bool,
pub started_from: StartedFrom,
#[serde(skip_serializing_if = "Option::is_none")]
pub log_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recurring: Option<String>,
#[serde(default = "one_run")]
pub runs: u64,
#[serde(default)]
pub failed_runs: u64,
}
fn one_run() -> u64 {
1
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum StartedFrom {
Workbench,
CommandLine,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum OpsEvent {
Started(Op),
Progress {
id: Uuid,
progress: f32,
label: Option<String>,
},
Completed(Op),
Failed(Op),
Snapshot {
ops: Vec<Op>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputLine {
pub seq: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ts: Option<String>,
#[serde(flatten)]
pub record: wsb::output::Record,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AdoptedLine {
#[serde(default)]
pub ts: Option<String>,
#[serde(flatten)]
pub record: wsb::output::Record,
}
#[derive(Debug, Clone)]
pub enum OutputEvent {
Line(OutputLine),
Closed,
}
const OUTPUT_LINE_CAPACITY: usize = 4000;
struct OutputBuffer {
lines: VecDeque<OutputLine>,
next_seq: u64,
dropped: u64,
closed: bool,
tx: broadcast::Sender<OutputEvent>,
}
impl OutputBuffer {
fn new() -> Self {
let (tx, _) = broadcast::channel(1024);
Self {
lines: VecDeque::new(),
next_seq: 0,
dropped: 0,
closed: false,
tx,
}
}
fn append(&mut self, record: wsb::output::Record, ts: Option<String>) -> OutputLine {
let line = OutputLine {
seq: self.next_seq,
ts,
record,
};
self.next_seq += 1;
self.lines.push_back(line.clone());
while self.lines.len() > OUTPUT_LINE_CAPACITY {
self.lines.pop_front();
self.dropped += 1;
}
let _ = self.tx.send(OutputEvent::Line(line.clone()));
line
}
fn close(&mut self) {
if self.closed {
return;
}
self.closed = true;
let _ = self.tx.send(OutputEvent::Closed);
}
}
struct Inner {
ops: VecDeque<Op>,
outputs: HashMap<Uuid, OutputBuffer>,
}
const HISTORY_CAPACITY: usize = 100;
impl Inner {
fn new() -> Self {
Self {
ops: VecDeque::new(),
outputs: HashMap::new(),
}
}
fn insert(&mut self, op: Op) {
self.ops.push_back(op);
}
fn forget(&mut self, id: Uuid) {
self.outputs.remove(&id);
}
fn update_progress(&mut self, id: Uuid, progress: f32, label: Option<String>) {
if let Some(op) = self.ops.iter_mut().find(|o| o.id == id) {
op.progress = progress.clamp(0.0, 1.0);
if let Some(ref l) = label {
op.label = l.clone();
}
}
}
fn resolve(&mut self, id: Uuid, status: OpStatus, error: Option<String>) {
if let Some(op) = self.ops.iter_mut().find(|o| o.id == id) {
if status == OpStatus::Failed && op.status != OpStatus::Failed {
op.failed_runs += 1;
}
op.status = status;
op.error = error;
op.ended_at = Some(Utc::now());
if status != OpStatus::Running {
op.progress = if status == OpStatus::Completed {
1.0
} else {
op.progress
};
}
}
if let Some(buffer) = self.outputs.get_mut(&id) {
buffer.close();
}
while self
.ops
.iter()
.filter(|o| o.status != OpStatus::Running)
.count()
> HISTORY_CAPACITY
{
if let Some(idx) = self.ops.iter().position(|o| o.status != OpStatus::Running) {
let evicted = self.ops.remove(idx);
if let Some(op) = evicted {
self.forget(op.id);
}
} else {
break;
}
}
}
fn snapshot(&self) -> Vec<Op> {
self.ops.iter().cloned().collect()
}
}
#[derive(Clone)]
pub struct OpsRegistry {
inner: Arc<RwLock<Inner>>,
tx: broadcast::Sender<OpsEvent>,
logs_root: Option<Arc<PathBuf>>,
}
impl OpsRegistry {
pub fn new() -> Self {
let (tx, _) = broadcast::channel(256);
Self {
inner: Arc::new(RwLock::new(Inner::new())),
tx,
logs_root: None,
}
}
pub fn with_logs(logs_root: PathBuf) -> Self {
Self {
logs_root: Some(Arc::new(logs_root)),
..Self::new()
}
}
pub fn logs_root(&self) -> Option<&Path> {
self.logs_root.as_deref().map(PathBuf::as_path)
}
pub async fn protected_logs(&self) -> Vec<PathBuf> {
let inner = self.inner.read().await;
let mut held: Vec<PathBuf> = inner
.ops
.iter()
.filter(|op| op.status == OpStatus::Running)
.filter_map(|op| op.log_path.as_deref())
.map(PathBuf::from)
.collect();
held.sort();
held.dedup();
held
}
pub async fn start_op(&self, kind: OpKind, label: impl Into<String>) -> OpHandle {
let label = label.into();
let started_at = Utc::now();
let recurring = kind.recurs().then(|| {
serde_json::to_value(kind)
.ok()
.and_then(|value| value.as_str().map(str::to_string))
.expect("an OpKind serialises to its name")
});
let (id, runs, failed_runs) = match &recurring {
Some(key) => {
let mut inner = self.inner.write().await;
let previous = inner.ops.iter().position(|o| {
o.recurring.as_deref() == Some(key.as_str()) && o.status != OpStatus::Running
});
match previous.and_then(|index| inner.ops.remove(index)) {
Some(last) => (last.id, last.runs + 1, last.failed_runs),
None => (Uuid::new_v4(), 1, 0),
}
}
None => (Uuid::new_v4(), 1, 0),
};
let op = Op {
id,
kind,
label,
status: OpStatus::Running,
progress: 0.0,
error: None,
started_at,
ended_at: None,
has_output: false,
started_from: StartedFrom::Workbench,
log_path: None,
recurring,
runs,
failed_runs,
};
let event = OpsEvent::Started(op.clone());
self.inner.write().await.insert(op);
let _ = self.tx.send(event);
OpHandle {
id,
registry: self.clone(),
resolved: false,
}
}
#[allow(clippy::too_many_arguments)]
pub async fn adopt(
&self,
id: Uuid,
kind: OpKind,
label: String,
started_at: DateTime<Utc>,
log_path: Option<String>,
lines: Vec<AdoptedLine>,
dropped: u64,
) -> Result<(), String> {
let op = Op {
id,
kind,
label,
status: OpStatus::Running,
progress: 0.0,
error: None,
started_at,
ended_at: None,
has_output: true,
started_from: StartedFrom::CommandLine,
log_path,
recurring: None,
runs: 1,
failed_runs: 0,
};
{
let mut inner = self.inner.write().await;
if inner.ops.iter().any(|held| held.id == id) {
return Err(format!("operation {id} is already known to this daemon"));
}
inner.insert(op.clone());
let mut buffer = OutputBuffer::new();
buffer.dropped = dropped;
for line in lines {
buffer.append(line.record, line.ts);
}
inner.outputs.insert(id, buffer);
}
let _ = self.tx.send(OpsEvent::Started(op));
Ok(())
}
pub async fn adopted_lines(&self, id: Uuid, lines: Vec<AdoptedLine>) -> Result<(), String> {
let mut inner = self.inner.write().await;
let buffer = inner
.outputs
.get_mut(&id)
.ok_or_else(|| format!("no operation {id} is being reported to this daemon"))?;
for line in lines {
buffer.append(line.record, line.ts);
}
Ok(())
}
pub async fn adopted_finished(&self, id: Uuid, exit_code: i32) -> Result<(), String> {
{
let inner = self.inner.read().await;
if !inner.ops.iter().any(|held| held.id == id) {
return Err(format!("no operation {id} is being reported to this daemon"));
}
}
let (status, error) = if exit_code == 0 {
(OpStatus::Completed, None)
} else {
(
OpStatus::Failed,
Some(format!("exited with status {exit_code}")),
)
};
self.resolve_op(id, status, error).await;
self.close_output(id).await;
Ok(())
}
pub async fn close_output(&self, id: Uuid) {
let mut inner = self.inner.write().await;
if let Some(buffer) = inner.outputs.get_mut(&id) {
buffer.close();
}
}
async fn buffered_snapshot(&self, id: Uuid) -> Option<(Vec<OutputLine>, u64, bool)> {
let inner = self.inner.read().await;
inner.outputs.get(&id).map(|buffer| {
(
buffer.lines.iter().cloned().collect(),
buffer.dropped,
buffer.closed,
)
})
}
pub async fn output_snapshot(&self, id: Uuid) -> Option<(Vec<OutputLine>, u64, bool)> {
let buffered = self.buffered_snapshot(id).await;
match &buffered {
Some((_, 0, _)) => return buffered,
_ => {}
}
let Some(path) = self.log_path_of(id).await else {
return buffered;
};
match tokio::task::spawn_blocking(move || super::op_log::read(&path)).await {
Ok(Ok(log)) => {
let closed = log.footer.is_some();
let lines = log
.lines
.into_iter()
.map(super::op_log::output_line_of)
.collect();
Some((lines, 0, closed))
}
Ok(Err(error)) => {
tracing::warn!(%error, "falling back to the in-memory output buffer");
buffered
}
Err(error) => {
tracing::warn!(%error, "reading an operation log panicked");
buffered
}
}
}
async fn log_path_of(&self, id: Uuid) -> Option<PathBuf> {
{
let inner = self.inner.read().await;
if let Some(op) = inner.ops.iter().find(|o| o.id == id) {
if let Some(path) = op.log_path.as_ref() {
return Some(PathBuf::from(path));
}
}
}
let root = self.logs_root.clone()?;
let found =
tokio::task::spawn_blocking(move || wsb::logs::find_op_log(&root, &id)).await;
found.ok().flatten()
}
pub async fn subscribe_output(&self, id: Uuid) -> Option<broadcast::Receiver<OutputEvent>> {
let inner = self.inner.read().await;
inner.outputs.get(&id).map(|buffer| buffer.tx.subscribe())
}
pub async fn report_progress(&self, id: Uuid, progress: f32, label: Option<String>) {
{
let mut inner = self.inner.write().await;
inner.update_progress(id, progress, label.clone());
}
let _ = self.tx.send(OpsEvent::Progress {
id,
progress,
label,
});
}
async fn complete(&self, id: Uuid) {
self.resolve_op(id, OpStatus::Completed, None).await;
}
async fn fail(&self, id: Uuid, error: String) {
self.resolve_op(id, OpStatus::Failed, Some(error)).await;
}
async fn resolve_op(&self, id: Uuid, status: OpStatus, error: Option<String>) {
let op = {
let mut inner = self.inner.write().await;
inner.resolve(id, status, error);
inner.ops.iter().find(|o| o.id == id).cloned()
};
if let Some(op) = op {
let event = match status {
OpStatus::Completed => OpsEvent::Completed(op),
_ => OpsEvent::Failed(op),
};
let _ = self.tx.send(event);
}
}
pub async fn resolve_finished_adopted(&self) {
let candidates: Vec<(Uuid, String)> = {
let inner = self.inner.read().await;
inner
.ops
.iter()
.filter(|op| {
op.status == OpStatus::Running
&& op.started_from == StartedFrom::CommandLine
})
.filter_map(|op| op.log_path.clone().map(|path| (op.id, path)))
.collect()
};
for (id, path) in candidates {
let envelope = tokio::task::spawn_blocking(move || {
super::op_log::read_envelope(std::path::Path::new(&path))
})
.await;
let Ok(Ok((_, Some(footer)))) = envelope else {
continue;
};
let (status, error) = match footer.status {
super::op_log::Status::Completed => (OpStatus::Completed, None),
super::op_log::Status::Failed => (OpStatus::Failed, footer.error.clone()),
};
self.resolve_op(id, status, error).await;
}
}
pub async fn snapshot(&self) -> Vec<Op> {
self.inner.read().await.snapshot()
}
pub async fn adopt_recent_logs(&self, limit: usize) -> usize {
let Some(root) = self.logs_root.as_deref().cloned() else {
return 0;
};
let found = tokio::task::spawn_blocking(move || {
super::op_log::recent_logs(&root, limit)
.into_iter()
.filter_map(|path| {
super::op_log::read_envelope(&path)
.ok()
.map(|(header, footer)| (path, header, footer))
})
.collect::<Vec<_>>()
})
.await
.unwrap_or_default();
let mut adopted = 0;
let mut inner = self.inner.write().await;
for (path, header, footer) in found.into_iter().rev() {
if inner.ops.iter().any(|op| op.id == header.id) {
continue;
}
let (status, ended_at, error) = match &footer {
Some(footer) => (
match footer.status {
super::op_log::Status::Completed => OpStatus::Completed,
super::op_log::Status::Failed => OpStatus::Failed,
},
Some(footer.ended_at),
footer.error.clone(),
),
None => (
OpStatus::Failed,
None,
Some("the daemon stopped while this was running".to_string()),
),
};
inner.insert(Op {
id: header.id,
kind: OpKind::Command,
label: header.label,
status,
progress: if status == OpStatus::Completed { 1.0 } else { 0.0 },
error,
started_at: header.started_at,
ended_at,
has_output: true,
started_from: StartedFrom::CommandLine,
log_path: Some(path.display().to_string()),
recurring: None,
runs: 1,
failed_runs: 0,
});
adopted += 1;
}
adopted
}
pub fn subscribe(&self) -> broadcast::Receiver<OpsEvent> {
self.tx.subscribe()
}
}
pub struct OpHandle {
id: Uuid,
registry: OpsRegistry,
resolved: bool,
}
impl OpHandle {
pub fn id(&self) -> Uuid {
self.id
}
pub async fn progress(&self, progress: f32) {
self.registry.report_progress(self.id, progress, None).await;
}
pub async fn complete(mut self) {
self.resolved = true;
self.registry.complete(self.id).await;
}
pub async fn fail(mut self, error: impl Into<String>) {
self.resolved = true;
self.registry.fail(self.id, error.into()).await;
}
}
impl Drop for OpHandle {
fn drop(&mut self) {
if self.resolved {
return;
}
let id = self.id;
let registry = self.registry.clone();
tokio::spawn(async move {
registry
.fail(id, "dropped without completion".to_string())
.await;
});
}
}
pub async fn ops_snapshot(State(state): State<WebState>) -> Json<Vec<Op>> {
Json(state.ops.snapshot().await)
}
pub async fn op_live_ids(State(state): State<WebState>) -> Json<Vec<Uuid>> {
let known = state.ops.snapshot().await;
let mut live = Vec::with_capacity(known.len());
for op in known {
if state.ops.output_snapshot(op.id).await.is_some() {
live.push(op.id);
}
}
Json(live)
}
pub async fn op_output_stream(
State(state): State<WebState>,
AxumPath(id): AxumPath<Uuid>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, (StatusCode, Json<serde_json::Value>)>
{
let registry = state.ops.clone();
let rx = registry.subscribe_output(id).await;
let Some((lines, dropped, closed)) = registry.output_snapshot(id).await else {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": format!("operation {id} has no captured output"),
})),
));
};
let closed = closed || rx.is_none();
let stream = async_stream::stream! {
let mut last_seq: i64 = lines.last().map(|l| l.seq as i64).unwrap_or(-1);
let snapshot = serde_json::json!({
"event": "snapshot",
"lines": lines,
"dropped": dropped,
"closed": closed,
});
yield Ok(Event::default().event("output").data(snapshot.to_string()));
if closed {
return;
}
let Some(mut rx) = rx else {
return;
};
loop {
match rx.recv().await {
Ok(OutputEvent::Line(line)) => {
if (line.seq as i64) <= last_seq {
continue;
}
last_seq = line.seq as i64;
let payload = serde_json::json!({
"event": "line",
"line": line,
});
yield Ok(Event::default().event("output").data(payload.to_string()));
}
Ok(OutputEvent::Closed) => {
let payload = serde_json::json!({ "event": "closed" });
yield Ok(Event::default().event("output").data(payload.to_string()));
break;
}
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!("op output SSE client lagged by {} lines", n);
let Some((lines, dropped, closed)) = registry.output_snapshot(id).await else {
break;
};
last_seq = lines.last().map(|l| l.seq as i64).unwrap_or(-1);
let payload = serde_json::json!({
"event": "snapshot",
"lines": lines,
"dropped": dropped,
"closed": closed,
});
yield Ok(Event::default().event("output").data(payload.to_string()));
if closed {
break;
}
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
};
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
}
#[derive(Debug, Deserialize)]
pub struct AdoptRequest {
pub id: Uuid,
pub label: String,
pub started_at: DateTime<Utc>,
#[serde(default)]
pub log_path: Option<String>,
#[serde(default)]
pub lines: Vec<AdoptedLine>,
#[serde(default)]
pub dropped: u64,
}
pub async fn op_adopt(
State(state): State<WebState>,
Json(request): Json<AdoptRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let kind = OpKind::Command;
let label = request.label;
state
.ops
.adopt(
request.id,
kind,
label,
request.started_at,
request.log_path,
request.lines,
request.dropped,
)
.await
.map_err(|error| {
(
StatusCode::CONFLICT,
Json(serde_json::json!({ "error": error })),
)
})?;
Ok(Json(serde_json::json!({ "adopted": request.id })))
}
#[derive(Debug, Deserialize)]
pub struct AdoptedLinesRequest {
pub lines: Vec<AdoptedLine>,
}
pub async fn op_adopted_lines(
State(state): State<WebState>,
AxumPath(id): AxumPath<Uuid>,
Json(request): Json<AdoptedLinesRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let taken = request.lines.len();
state
.ops
.adopted_lines(id, request.lines)
.await
.map_err(|error| {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": error })),
)
})?;
Ok(Json(serde_json::json!({ "lines": taken })))
}
#[derive(Debug, Deserialize)]
pub struct AdoptedFinishedRequest {
pub exit_code: i32,
}
pub async fn op_adopted_finished(
State(state): State<WebState>,
AxumPath(id): AxumPath<Uuid>,
Json(request): Json<AdoptedFinishedRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
state
.ops
.adopted_finished(id, request.exit_code)
.await
.map_err(|error| {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": error })),
)
})?;
Ok(Json(serde_json::json!({ "finished": id })))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn op_lifecycle_running_to_completed() {
let registry = OpsRegistry::new();
let mut rx = registry.subscribe();
let handle = registry.start_op(OpKind::CatalogueScan, "sync test").await;
let id = handle.id();
let started_event = rx.try_recv().expect("Started event should be immediate");
match started_event {
OpsEvent::Started(ref op) => {
assert_eq!(op.id, id);
assert_eq!(op.status, OpStatus::Running);
assert_eq!(op.label, "sync test");
}
other => panic!("expected Started, got {:?}", other),
}
handle.progress(0.5).await;
let progress_event = rx.try_recv().expect("Progress event should be broadcast");
match progress_event {
OpsEvent::Progress {
id: eid, progress, ..
} => {
assert_eq!(eid, id);
assert!((progress - 0.5).abs() < f32::EPSILON);
}
other => panic!("expected Progress, got {:?}", other),
}
handle.complete().await;
let completed_event = rx.try_recv().expect("Completed event should be broadcast");
match completed_event {
OpsEvent::Completed(ref op) => {
assert_eq!(op.id, id);
assert_eq!(op.status, OpStatus::Completed);
assert!((op.progress - 1.0).abs() < f32::EPSILON);
assert!(op.ended_at.is_some());
}
other => panic!("expected Completed, got {:?}", other),
}
let snap = registry.snapshot().await;
let found = snap
.iter()
.find(|o| o.id == id)
.expect("op must be in snapshot");
assert_eq!(found.status, OpStatus::Completed);
}
#[tokio::test]
async fn op_lifecycle_running_to_failed() {
let registry = OpsRegistry::new();
let mut rx = registry.subscribe();
let handle = registry.start_op(OpKind::GitMutation, "push origin").await;
let id = handle.id();
let _ = rx.try_recv();
handle.fail("authentication failure").await;
let event = rx.try_recv().expect("Failed event should be broadcast");
match event {
OpsEvent::Failed(ref op) => {
assert_eq!(op.id, id);
assert_eq!(op.status, OpStatus::Failed);
assert_eq!(op.error.as_deref(), Some("authentication failure"));
}
other => panic!("expected Failed, got {:?}", other),
}
}
#[tokio::test]
async fn unresolved_drop_fails_the_op() {
let registry = OpsRegistry::new();
let mut rx = registry.subscribe();
let id = {
let handle = registry.start_op(OpKind::GitScan, "scan").await;
let id = handle.id();
let _ = rx.try_recv(); id
};
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
let snap = registry.snapshot().await;
let found = snap
.iter()
.find(|o| o.id == id)
.expect("op must still be in snapshot");
assert_eq!(
found.status,
OpStatus::Failed,
"dropped op must be Failed, not {:?}",
found.status
);
assert_eq!(found.error.as_deref(), Some("dropped without completion"));
}
#[tokio::test]
async fn concurrent_ops_are_independent() {
let registry = OpsRegistry::new();
let mut handles: Vec<OpHandle> = Vec::new();
let mut ids: Vec<Uuid> = Vec::new();
for i in 0..10 {
let h = registry
.start_op(OpKind::GitScan, format!("scan {}", i))
.await;
ids.push(h.id());
handles.push(h);
}
for h in handles {
h.progress(1.0).await;
h.complete().await;
}
let snap = registry.snapshot().await;
for id in &ids {
let op = snap
.iter()
.find(|o| o.id == *id)
.expect("every op must be in snapshot");
assert_eq!(
op.status,
OpStatus::Completed,
"op {} must be Completed",
id
);
}
}
#[tokio::test]
async fn history_ring_buffer_evicts_old_completed() {
let registry = OpsRegistry::new();
for i in 0..(HISTORY_CAPACITY + 20) {
let h = registry.start_op(OpKind::GitMutation, format!("commit {}", i)).await;
h.complete().await;
}
let snap = registry.snapshot().await;
let completed: Vec<_> = snap
.iter()
.filter(|o| o.status == OpStatus::Completed)
.collect();
assert!(
completed.len() <= HISTORY_CAPACITY,
"completed ops {} must not exceed capacity {}",
completed.len(),
HISTORY_CAPACITY
);
}
#[tokio::test]
async fn test12151_a_recurring_task_is_one_row_and_evicts_nothing() {
let registry = OpsRegistry::new();
let build = registry.start_op(OpKind::GitMutation, "a real commit").await;
let build_id = build.id();
build.complete().await;
let mut ids = std::collections::HashSet::new();
for _ in 0..(HISTORY_CAPACITY + 50) {
let h = registry
.start_op(OpKind::CatalogueScan, "Sync history")
.await;
ids.insert(h.id());
h.complete().await;
}
let snap = registry.snapshot().await;
let syncs: Vec<_> = snap.iter().filter(|o| o.label == "Sync history").collect();
assert_eq!(syncs.len(), 1, "every run of one recurring task is one row: {}", syncs.len());
assert_eq!(syncs[0].runs, (HISTORY_CAPACITY + 50) as u64, "the row counts its runs");
assert_eq!(ids.len(), 1, "the row keeps one id, so a page updates it in place");
assert!(
snap.iter().any(|o| o.id == build_id),
"a finished build was evicted by a recurring task's runs"
);
let first = registry.start_op(OpKind::GitScan, "Scan").await;
let second = registry.start_op(OpKind::GitScan, "Scan").await;
assert_ne!(first.id(), second.id(), "a run that overlaps another is a row of its own");
first.complete().await;
second.complete().await;
}
#[tokio::test]
async fn snapshot_includes_running_ops() {
let registry = OpsRegistry::new();
let h1 = registry.start_op(OpKind::GitScan, "scan").await;
let h2 = registry.start_op(OpKind::CatalogueScan, "sync").await;
let id1 = h1.id();
let id2 = h2.id();
let snap = registry.snapshot().await;
let ids_in_snap: Vec<Uuid> = snap.iter().map(|o| o.id).collect();
assert!(
ids_in_snap.contains(&id1),
"snapshot must include running op1"
);
assert!(
ids_in_snap.contains(&id2),
"snapshot must include running op2"
);
h1.complete().await;
h2.fail("err").await;
}
fn adopted(text: &str) -> AdoptedLine {
AdoptedLine {
ts: Some("2026-08-29T00:00:00Z".to_string()),
record: wsb::output::Record::Stdout {
text: text.to_string(),
},
}
}
async fn announce(registry: &OpsRegistry, id: Uuid, log_path: Option<String>) {
registry
.adopt(
id,
OpKind::Command,
"build".to_string(),
Utc::now(),
log_path,
Vec::new(),
0,
)
.await
.expect("a run this registry has not seen is adoptable");
}
fn command_line_log(
logs_root: &std::path::Path,
id: Uuid,
label: &str,
texts: &[String],
finished: bool,
) -> String {
let writer = wsb::logs::op_log::OpLogWriter::create(
logs_root,
id,
"test",
label,
&["wsb".to_string(), "test".to_string()],
Utc::now(),
)
.expect("a log is created");
for (seq, text) in texts.iter().enumerate() {
writer.write_line(&wsb::output::Line {
seq: seq as u64,
ts: "2026-08-29T00:00:00Z".to_string(),
record: wsb::output::Record::Stdout { text: text.clone() },
});
}
if finished {
writer.finish(wsb::logs::op_log::Status::Completed, None);
}
writer.path().display().to_string()
}
#[tokio::test]
async fn test8946_output_is_sequenced_and_eviction_is_counted() {
let registry = OpsRegistry::new();
let id = Uuid::new_v4();
announce(®istry, id, None).await;
let lines = (0..(OUTPUT_LINE_CAPACITY + 25))
.map(|i| adopted(&format!("line {i}")))
.collect();
registry
.adopted_lines(id, lines)
.await
.expect("an announced run may say more");
let (lines, dropped, closed) = registry
.output_snapshot(id)
.await
.expect("an announced run has an output buffer");
assert!(!closed);
assert_eq!(lines.len(), OUTPUT_LINE_CAPACITY);
assert_eq!(dropped, 25, "eviction must be counted, not silent");
assert_eq!(lines.first().unwrap().seq, 25);
assert_eq!(
lines.last().unwrap().seq as usize,
OUTPUT_LINE_CAPACITY + 24
);
}
#[tokio::test]
async fn test8986_a_long_run_is_served_whole_from_its_file() {
let dir = tempfile::tempdir().unwrap();
let id = Uuid::new_v4();
let total = OUTPUT_LINE_CAPACITY + 25;
let texts: Vec<String> = (0..total).map(|i| format!("line {i}")).collect();
let path = command_line_log(dir.path(), id, "build", &texts, true);
let registry = OpsRegistry::with_logs(dir.path().to_path_buf());
announce(®istry, id, Some(path)).await;
registry
.adopted_lines(id, texts.iter().map(|text| adopted(text)).collect())
.await
.expect("an announced run may say more");
registry
.adopted_finished(id, 0)
.await
.expect("an announced run may end");
let (lines, dropped, closed) = registry
.output_snapshot(id)
.await
.expect("the operation has output");
assert_eq!(lines.len(), total, "every line, not the ring's tail");
assert_eq!(dropped, 0, "nothing is missing, so nothing is reported as missing");
assert!(closed);
match &lines[0].record {
wsb::output::Record::Stdout { text } => assert_eq!(text, "line 0"),
other => panic!("the first line is not the first line: {other:?}"),
}
}
#[tokio::test]
async fn test8987_a_log_outlives_the_process_that_wrote_it() {
let dir = tempfile::tempdir().unwrap();
let id = Uuid::new_v4();
command_line_log(dir.path(), id, "the declared suite", &["it ran".to_string()], true);
let registry = OpsRegistry::with_logs(dir.path().to_path_buf());
let (lines, _, closed) = registry
.output_snapshot(id)
.await
.expect("a log on disk is reachable from its id alone");
assert!(closed, "a log with a footer is a finished operation");
assert_eq!(lines.len(), 1, "the line the command line wrote");
assert!(
registry.snapshot().await.is_empty(),
"history is empty until the directory is read"
);
assert_eq!(registry.adopt_recent_logs(50).await, 1);
let listed = registry.snapshot().await;
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, id);
assert_eq!(listed[0].status, OpStatus::Completed);
assert_eq!(listed[0].label, "the declared suite");
assert_eq!(listed[0].kind, OpKind::Command);
assert_eq!(listed[0].started_from, StartedFrom::CommandLine);
assert!(
listed[0].log_path.is_some(),
"an adopted operation must say where its log is"
);
let (lines, _, closed) = registry
.output_snapshot(id)
.await
.expect("the log is readable from disk");
assert!(closed, "a run from before this process is not still running");
match &lines[0].record {
wsb::output::Record::Stdout { text } => assert_eq!(text, "it ran"),
other => panic!("{other:?}"),
}
}
#[tokio::test]
async fn test8988_an_interrupted_run_is_adopted_as_failed() {
let dir = tempfile::tempdir().unwrap();
let id = Uuid::new_v4();
command_line_log(dir.path(), id, "the app", &["starting".to_string()], false);
let registry = OpsRegistry::with_logs(dir.path().to_path_buf());
registry.adopt_recent_logs(50).await;
let listed = registry.snapshot().await;
let op = listed.iter().find(|o| o.id == id).expect("it is listed");
assert_eq!(
op.status,
OpStatus::Failed,
"a run with no footer must not present itself as one that finished"
);
assert!(op.error.as_deref().unwrap_or_default().contains("stopped"));
}
#[tokio::test]
async fn test8947_resolving_closes_output() {
let registry = OpsRegistry::new();
let id = Uuid::new_v4();
announce(®istry, id, None).await;
let mut rx = registry
.subscribe_output(id)
.await
.expect("an announced run is subscribable");
registry
.adopted_lines(id, vec![adopted("one")])
.await
.expect("an announced run may say more");
registry
.adopted_finished(id, 1)
.await
.expect("an announced run may end");
match rx.try_recv().expect("line event") {
OutputEvent::Line(line) => assert_eq!(
line.record,
wsb::output::Record::Stdout {
text: "one".to_string()
}
),
OutputEvent::Closed => panic!("Closed arrived before the line"),
}
match rx.try_recv().expect("closed event") {
OutputEvent::Closed => {}
OutputEvent::Line(line) => panic!("expected Closed, got line {:?}", line.record),
}
let (_, _, closed) = registry.output_snapshot(id).await.expect("still in history");
assert!(closed, "a resolved op's output must be closed");
}
#[tokio::test]
async fn test8949_eviction_forgets_output() {
let registry = OpsRegistry::new();
let first_id = Uuid::new_v4();
announce(®istry, first_id, None).await;
registry
.adopted_lines(first_id, vec![adopted("kept?")])
.await
.expect("an announced run may say more");
registry
.adopted_finished(first_id, 0)
.await
.expect("an announced run may end");
assert!(registry.output_snapshot(first_id).await.is_some());
for i in 0..(HISTORY_CAPACITY + 1) {
let h = registry.start_op(OpKind::GitMutation, format!("commit {i}")).await;
h.complete().await;
}
assert!(
registry.output_snapshot(first_id).await.is_none(),
"an evicted op's output must be forgotten"
);
assert!(
registry.subscribe_output(first_id).await.is_none(),
"an evicted op must not be subscribable"
);
}
#[tokio::test]
async fn test11224_an_adopted_run_keeps_the_id_its_log_was_opened_under() {
let registry = OpsRegistry::new();
let id = Uuid::new_v4();
let started = Utc::now();
registry
.adopt(
id,
OpKind::Command,
"wsb update --recursive".to_string(),
started,
Some("/logs/update.jsonl".to_string()),
vec![adopted("reading the workspace")],
0,
)
.await
.expect("a run this registry has not seen is adoptable");
let snapshot = registry.snapshot().await;
let op = snapshot.iter().find(|op| op.id == id).expect("listed");
assert_eq!(op.status, OpStatus::Running);
assert_eq!(op.started_from, StartedFrom::CommandLine);
assert_eq!(op.log_path.as_deref(), Some("/logs/update.jsonl"));
assert_eq!(
op.started_at, started,
"the run's own start time was replaced with the moment it was announced"
);
assert!(op.has_output, "an adopted run's output must be readable");
let (lines, dropped, closed) = registry.output_snapshot(id).await.expect("output");
assert_eq!(lines.len(), 1, "the announcement's lines were not kept");
assert_eq!(dropped, 0);
assert!(!closed);
}
#[tokio::test]
async fn test11405_a_command_line_runs_log_is_protected_while_it_runs() {
let registry = OpsRegistry::new();
let id = Uuid::new_v4();
registry
.adopt(
id,
OpKind::Command,
"wsb test --on pve-windows-11-24h2-x86-64-cuda-pve".to_string(),
Utc::now(),
Some("/logs/ops/2026-09-18/125417-test.jsonl".to_string()),
vec![adopted("mg-test: starting")],
0,
)
.await
.expect("a run started from a terminal announces itself");
let protected = registry.protected_logs().await;
let shown: Vec<String> = protected
.iter()
.map(|path| path.display().to_string())
.collect();
assert!(
shown.iter().any(|p| p.ends_with("125417-test.jsonl")),
"the log of a RUNNING command-line operation is not protected from \
the sweep: {shown:?}"
);
}
#[tokio::test]
async fn test11406_a_finished_runs_log_is_no_longer_protected() {
let registry = OpsRegistry::new();
let id = Uuid::new_v4();
registry
.adopt(
id,
OpKind::Command,
"wsb test --on pve".to_string(),
Utc::now(),
Some("/logs/ops/2026-09-18/done.jsonl".to_string()),
vec![adopted("mg-test: starting")],
0,
)
.await
.expect("announced");
assert_eq!(
registry.protected_logs().await.len(),
1,
"a running run's log is protected"
);
registry
.adopted_finished(id, 0)
.await
.expect("a run that ended says so");
let protected = registry.protected_logs().await;
assert!(
protected.is_empty(),
"a finished run's log must be sweepable again, or the limit is a \
number nothing enforces: {protected:?}"
);
}
#[tokio::test]
async fn test11225_an_adopted_runs_lines_and_outcome_reach_the_registry() {
let registry = OpsRegistry::new();
let id = Uuid::new_v4();
registry
.adopt(
id,
OpKind::Command,
"wsb ship".to_string(),
Utc::now(),
None,
vec![adopted("first")],
0,
)
.await
.unwrap();
registry
.adopted_lines(id, vec![adopted("second"), adopted("third")])
.await
.expect("a run this registry is holding may say more");
registry
.adopted_finished(id, 2)
.await
.expect("a run this registry is holding may end");
let (lines, _, closed) = registry.output_snapshot(id).await.expect("output");
assert_eq!(lines.len(), 3, "lines sent after the announcement were lost");
assert!(closed, "a finished run's output must be closed to subscribers");
assert_eq!(
lines.iter().map(|line| line.seq).collect::<Vec<_>>(),
vec![0, 1, 2]
);
let snapshot = registry.snapshot().await;
let op = snapshot.iter().find(|op| op.id == id).expect("listed");
assert_eq!(
op.status,
OpStatus::Failed,
"a run that exited non-zero was shown as having succeeded"
);
assert!(op.error.is_some(), "a failed run must say something about why");
}
#[tokio::test]
async fn test11226_lines_for_an_unknown_run_are_refused() {
let registry = OpsRegistry::new();
let unknown = Uuid::new_v4();
assert!(
registry.adopted_lines(unknown, vec![adopted("x")]).await.is_err(),
"lines were accepted for a run this registry never heard of"
);
assert!(
registry.adopted_finished(unknown, 0).await.is_err(),
"an outcome was accepted for a run this registry never heard of"
);
assert!(registry.snapshot().await.is_empty());
}
#[tokio::test]
async fn test11227_a_run_cannot_be_announced_twice() {
let registry = OpsRegistry::new();
let id = Uuid::new_v4();
let announce = || {
registry.adopt(
id,
OpKind::Command,
"wsb update".to_string(),
Utc::now(),
None,
Vec::new(),
0,
)
};
announce().await.expect("the first announcement is taken");
assert!(
announce().await.is_err(),
"the same run was announced twice and got two rows"
);
assert_eq!(registry.snapshot().await.len(), 1);
}
fn log_with_footer(dir: &std::path::Path, id: Uuid, status: &str) -> String {
let path = dir.join(format!("{id}.jsonl"));
let header = serde_json::json!({
"log": "header", "format": "wsb-op-log/1", "id": id,
"kind": "checkpoint", "label": "checkpointing",
"argv": ["wsb", "checkpoint"], "started_at": Utc::now(),
});
let footer = serde_json::json!({
"log": "footer", "status": status, "ended_at": Utc::now(), "lines": 1,
});
std::fs::write(&path, format!("{header}\n{footer}\n")).expect("write log");
path.display().to_string()
}
#[tokio::test]
async fn test11228_a_finished_adopted_run_resolves_from_its_log() {
let dir = std::env::temp_dir().join(format!("wsb-ops-{}", Uuid::new_v4()));
std::fs::create_dir_all(&dir).expect("scratch");
let registry = OpsRegistry::new();
let finished = Uuid::new_v4();
let path = log_with_footer(&dir, finished, "failed");
registry
.adopt(
finished,
OpKind::Command,
"wsb checkpoint".to_string(),
Utc::now(),
Some(path),
vec![],
0,
)
.await
.expect("adoptable");
let before = registry.snapshot().await;
assert_eq!(
before.iter().find(|o| o.id == finished).expect("listed").status,
OpStatus::Running,
);
registry.resolve_finished_adopted().await;
let after = registry.snapshot().await;
assert_eq!(
after.iter().find(|o| o.id == finished).expect("listed").status,
OpStatus::Failed,
"a row stayed running for a process that had already written how it ended"
);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn test11229_a_running_adopted_run_is_not_resolved() {
let dir = std::env::temp_dir().join(format!("wsb-ops-{}", Uuid::new_v4()));
std::fs::create_dir_all(&dir).expect("scratch");
let registry = OpsRegistry::new();
let live = Uuid::new_v4();
let path = dir.join(format!("{live}.jsonl"));
let header = serde_json::json!({
"log": "header", "format": "wsb-op-log/1", "id": live,
"kind": "test_run", "label": "running the declared suite",
"argv": ["wsb", "test"], "started_at": Utc::now(),
});
std::fs::write(&path, format!("{header}\n")).expect("write log");
registry
.adopt(
live,
OpKind::Command,
"wsb test".to_string(),
Utc::now(),
Some(path.display().to_string()),
vec![],
0,
)
.await
.expect("adoptable");
registry.resolve_finished_adopted().await;
let after = registry.snapshot().await;
assert_eq!(
after.iter().find(|o| o.id == live).expect("listed").status,
OpStatus::Running,
"a run with no footer was reported as finished; it may still be going"
);
std::fs::remove_dir_all(&dir).ok();
}
}