wsb 0.164.718

Workspace - A tool suite for file operations, version management, and development workflow automation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
//! Lightweight background-operation registry.
//!
//! Every background task the server runs (git status scan, sync, git mutations)
//! registers itself here. The registry is in-memory only; nothing is persisted.
//!
//! # Design
//!
//! - `OpsRegistry` is a cheap-to-clone handle (Arc inside).
//! - `start_op(kind, label)` creates a `Running` op and returns an `OpHandle`.
//! - `OpHandle` is an RAII guard: call `.complete()` or `.fail(msg)` when the
//!   operation finishes; if the handle is dropped without being resolved the op
//!   is automatically marked `Failed` with a "dropped without completion" error.
//! - A `broadcast::Sender<OpsEvent>` fans out every state change to all SSE
//!   subscribers in real-time.
//! - Completed / failed ops are kept in a ring-buffer (capacity 100) so new
//!   SSE clients get recent history on connect.
use crate::prelude::*;

use axum::{
    extract::State,
    response::sse::{Event, KeepAlive, Sse},
    Json,
};
use chrono::{DateTime, Utc};
use futures::stream::Stream;
use std::collections::VecDeque;
use std::convert::Infallible;
use tokio::sync::{broadcast, RwLock};
use uuid::Uuid;

use super::state::WebState;

// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------

/// The kind of background operation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OpKind {
    /// Background git status scan of all repos in the workspace.
    GitScan,
    /// A user-initiated git mutation (stage/commit/push/pull/checkout/etc.).
    GitMutation,
    /// Sync of Claude session files from disk into the database.
    Sync,
}

/// Lifecycle state of an operation. An op always starts `Running`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OpStatus {
    Running,
    Completed,
    Failed,
}

/// A single operation record, snapshotted for serialization.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Op {
    pub id: Uuid,
    pub kind: OpKind,
    pub label: String,
    pub status: OpStatus,
    /// 0.0 → 1.0; meaningful while Running.
    pub progress: f32,
    /// Non-null only when status is Failed.
    pub error: Option<String>,
    pub started_at: DateTime<Utc>,
    pub ended_at: Option<DateTime<Utc>>,
}

/// Events broadcast to SSE subscribers on every state change.
#[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),
    /// Emitted once on initial SSE connect to replay the current snapshot.
    /// Contains all ops (running + recent completed/failed).
    Snapshot { ops: Vec<Op> },
}

// ---------------------------------------------------------------------------
// Internal state
// ---------------------------------------------------------------------------

struct Inner {
    /// All currently-running ops plus the last `HISTORY_CAPACITY` completed/failed.
    ops: VecDeque<Op>,
}

const HISTORY_CAPACITY: usize = 100;

impl Inner {
    fn new() -> Self {
        Self {
            ops: VecDeque::new(),
        }
    }

    fn insert(&mut self, op: Op) {
        self.ops.push_back(op);
    }

    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) {
            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 };
            }
        }
        // Evict oldest non-running entries beyond history cap.
        while self.ops.iter().filter(|o| o.status != OpStatus::Running).count() > HISTORY_CAPACITY {
            // Find index of the oldest finished op.
            if let Some(idx) = self.ops.iter().position(|o| o.status != OpStatus::Running) {
                self.ops.remove(idx);
            } else {
                break;
            }
        }
    }

    fn snapshot(&self) -> Vec<Op> {
        self.ops.iter().cloned().collect()
    }
}

// ---------------------------------------------------------------------------
// OpsRegistry — the shared handle
// ---------------------------------------------------------------------------

/// Thread-safe, cheaply-cloneable handle to the ops registry.
#[derive(Clone)]
pub struct OpsRegistry {
    inner: Arc<RwLock<Inner>>,
    tx: broadcast::Sender<OpsEvent>,
}

impl OpsRegistry {
    pub fn new() -> Self {
        let (tx, _) = broadcast::channel(256);
        Self {
            inner: Arc::new(RwLock::new(Inner::new())),
            tx,
        }
    }

    /// Start a new operation. Returns an `OpHandle` that must be resolved.
    pub async fn start_op(&self, kind: OpKind, label: impl Into<String>) -> OpHandle {
        let op = Op {
            id: Uuid::new_v4(),
            kind,
            label: label.into(),
            status: OpStatus::Running,
            progress: 0.0,
            error: None,
            started_at: Utc::now(),
            ended_at: None,
        };
        let id = op.id;
        let event = OpsEvent::Started(op.clone());
        {
            let mut inner = self.inner.write().await;
            inner.insert(op.clone());
        }
        let _ = self.tx.send(event);
        OpHandle {
            id,
            registry: self.clone(),
            resolved: false,
        }
    }

    /// Update an op's progress (0.0–1.0) and optionally change its label.
    /// Silently ignored if the op id is not found (e.g. already completed).
    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 });
    }

    /// Mark an op as completed.
    async fn complete(&self, id: Uuid) {
        let op = {
            let mut inner = self.inner.write().await;
            inner.resolve(id, OpStatus::Completed, None);
            inner.ops.iter().find(|o| o.id == id).cloned()
        };
        if let Some(op) = op {
            let _ = self.tx.send(OpsEvent::Completed(op));
        }
    }

    /// Mark an op as failed with an error message.
    async fn fail(&self, id: Uuid, error: String) {
        let op = {
            let mut inner = self.inner.write().await;
            inner.resolve(id, OpStatus::Failed, Some(error.clone()));
            inner.ops.iter().find(|o| o.id == id).cloned()
        };
        if let Some(op) = op {
            let _ = self.tx.send(OpsEvent::Failed(op));
        }
    }

    /// Return a snapshot of all current ops (running + recent history).
    pub async fn snapshot(&self) -> Vec<Op> {
        self.inner.read().await.snapshot()
    }

    /// Subscribe to the broadcast stream of events.
    pub fn subscribe(&self) -> broadcast::Receiver<OpsEvent> {
        self.tx.subscribe()
    }
}

// ---------------------------------------------------------------------------
// OpHandle — RAII guard
// ---------------------------------------------------------------------------

/// A handle to a running operation. Must be explicitly resolved via
/// `.complete()` or `.fail(msg)`. If dropped without resolution, the op is
/// automatically failed with "dropped without completion".
pub struct OpHandle {
    id: Uuid,
    registry: OpsRegistry,
    resolved: bool,
}

impl OpHandle {
    pub fn id(&self) -> Uuid {
        self.id
    }

    /// Report progress without resolving. Safe to call many times.
    pub async fn progress(&self, progress: f32) {
        self.registry.report_progress(self.id, progress, None).await;
    }

    /// Report progress with a new label string.
    pub async fn progress_labeled(&self, progress: f32, label: impl Into<String>) {
        self.registry
            .report_progress(self.id, progress, Some(label.into()))
            .await;
    }

    /// Mark the op as successfully completed. Consumes the handle.
    pub async fn complete(mut self) {
        self.resolved = true;
        self.registry.complete(self.id).await;
    }

    /// Mark the op as failed. Consumes the handle.
    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;
        }
        // Synchronously schedule the fail — we can't await inside Drop.
        // Use try_write so we don't block the async runtime; if the lock
        // is contended we skip the eviction step (benign: the entry stays
        // Running until garbage-collected by the next successful write).
        let id = self.id;
        let registry = self.registry.clone();
        tokio::spawn(async move {
            registry.fail(id, "dropped without completion".to_string()).await;
        });
    }
}

// ---------------------------------------------------------------------------
// HTTP handlers
// ---------------------------------------------------------------------------

/// GET /api/v1/ops — returns a snapshot of all ops as JSON.
pub async fn ops_snapshot(
    State(state): State<WebState>,
) -> Json<Vec<Op>> {
    Json(state.ops.snapshot().await)
}

/// GET /api/v1/ops/stream — SSE stream of OpsEvent.
///
/// On connect, the current snapshot is immediately emitted as a single
/// `snapshot` event so the client has the full picture without waiting
/// for the next change. If the client falls behind in the broadcast buffer,
/// a fresh snapshot is re-fetched and re-emitted so the client stays consistent.
pub async fn ops_stream(
    State(state): State<WebState>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
    let registry = state.ops.clone();
    let mut rx = registry.subscribe();
    let snapshot = registry.snapshot().await;

    let stream = async_stream::stream! {
        // Emit the current snapshot first so new clients are immediately
        // consistent without waiting for the next broadcast event.
        let init = OpsEvent::Snapshot { ops: snapshot };
        if let Ok(json) = serde_json::to_string(&init) {
            yield Ok(Event::default().event("ops").data(json));
        }

        loop {
            match rx.recv().await {
                Ok(event) => {
                    match serde_json::to_string(&event) {
                        Ok(json) => yield Ok(Event::default().event("ops").data(json)),
                        Err(e) => {
                            // Serialization failure is a bug — surface it rather than silently drop.
                            yield Ok(Event::default().event("error").data(format!("ops serialize error: {}", e)));
                        }
                    }
                }
                Err(broadcast::error::RecvError::Lagged(n)) => {
                    // Fell behind in the broadcast buffer — re-emit the full snapshot
                    // so the client is consistent again.
                    tracing::warn!("ops SSE client lagged by {} events, re-sending snapshot", n);
                    let ops = registry.snapshot().await;
                    let recovery = OpsEvent::Snapshot { ops };
                    if let Ok(json) = serde_json::to_string(&recovery) {
                        yield Ok(Event::default().event("ops").data(json));
                    }
                }
                Err(broadcast::error::RecvError::Closed) => break,
            }
        }
    };

    Sse::new(stream).keep_alive(KeepAlive::default())
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    /// Verifies that a Running op transitions to Completed and broadcasts the
    /// right events. Also checks that the snapshot reflects the final state.
    #[tokio::test]
    async fn op_lifecycle_running_to_completed() {
        let registry = OpsRegistry::new();
        let mut rx = registry.subscribe();

        let handle = registry.start_op(OpKind::Sync, "sync test").await;
        let id = handle.id();

        // Should have broadcast a Started event.
        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),
        }

        // Update progress.
        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),
        }

        // Complete.
        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),
        }

        // Snapshot reflects final state.
        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);
    }

    /// Verifies that a Running op transitions to Failed when fail() is called.
    #[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(); // consume Started

        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),
        }
    }

    /// Verifies that dropping an unresolved OpHandle marks the op Failed.
    #[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(); // consume Started
            id
            // handle dropped here without complete/fail
        };

        // Give the spawned task time to run.
        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"));
    }

    /// Verifies that concurrent ops each receive independent broadcast events
    /// and that their IDs never cross-contaminate.
    #[tokio::test]
    async fn concurrent_ops_are_independent() {
        let registry = OpsRegistry::new();
        // Spawn 10 concurrent ops, each progresses to 1.0 and completes.
        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);
        }

        // Complete all.
        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);
        }
    }

    /// Verifies that the ring-buffer evicts old completed ops when it exceeds
    /// HISTORY_CAPACITY, while active (Running) ops are never evicted.
    #[tokio::test]
    async fn history_ring_buffer_evicts_old_completed() {
        let registry = OpsRegistry::new();

        // Fill beyond capacity.
        for i in 0..(HISTORY_CAPACITY + 20) {
            let h = registry.start_op(OpKind::Sync, format!("sync {}", i)).await;
            h.complete().await;
        }

        let snap = registry.snapshot().await;
        // Only HISTORY_CAPACITY completed ops retained.
        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
        );
    }

    /// Verifies that the snapshot emitted in ops_stream (the Snapshot event)
    /// contains all currently-running ops so a new client sees live work.
    #[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::Sync, "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;
    }
}