wsb 0.93.391653

Workspace - A tool suite for file operations, version management, and development workflow automation
Documentation
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
//! Sync engine — discovers Claude JSONL session files, parses them,
//! and upserts to the sessions database. Supports incremental sync
//! (skip unchanged files) and full resync. File watching via notify crate.

use anyhow::{Context, Result};
use std::collections::HashMap;

use super::db::SessionDb;
use super::parser::{self, DiscoveredFile, ParseResult};
use super::state::WebState;

/// Sync engine wrapping the session database.
pub struct SyncEngine {
    db: SessionDb,
    skip_cache: HashMap<String, i64>, // path → mtime (nanoseconds)
    last_sync_stats: Option<SyncStats>,
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct SyncStats {
    pub synced: usize,
    pub failed: usize,
    pub skipped: usize,
    pub total: usize,
    pub aborted: bool,
}

impl SyncEngine {
    pub fn new(db: SessionDb) -> Self {
        Self {
            db,
            skip_cache: HashMap::new(),
            last_sync_stats: None,
        }
    }

    pub fn last_stats(&self) -> Option<&SyncStats> {
        self.last_sync_stats.as_ref()
    }

    /// Load the skip cache from the database on startup.
    pub async fn load_skip_cache(&mut self) -> Result<()> {
        self.skip_cache = self.db.load_skip_cache().await?;
        Ok(())
    }

    /// Incremental sync: discover all Claude session files, skip unchanged,
    /// parse changed ones, upsert to DB.
    pub async fn sync_incremental(&mut self) -> Result<SyncStats> {
        let files = parser::discover_claude_sessions()?;
        let total = files.len();
        let mut synced = 0;
        let mut failed = 0;
        let mut skipped = 0;

        for file in &files {
            let path_str = file.path.to_string_lossy().to_string();

            // Check file metadata
            let metadata = match std::fs::metadata(&file.path) {
                Ok(m) => m,
                Err(e) => {
                    eprintln!(
                        "[wsb] sync: stat error {}: {}",
                        file.path.display(),
                        e
                    );
                    failed += 1;
                    continue;
                }
            };

            let file_size = metadata.len() as i64;
            let file_mtime = metadata
                .modified()
                .ok()
                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                .map(|d| d.as_nanos() as i64)
                .unwrap_or(0);

            // Check in-memory skip cache
            if let Some(&cached_mtime) = self.skip_cache.get(&path_str) {
                if cached_mtime == file_mtime {
                    skipped += 1;
                    continue;
                }
            }

            // Check DB skip (by session ID for Claude)
            if let Ok(Some((stored_size, stored_mtime))) = self
                .db
                .get_session_file_info(&file.session_id)
                .await
            {
                if stored_size == file_size && stored_mtime == file_mtime {
                    skipped += 1;
                    continue;
                }
            }

            // Check if excluded
            if let Ok(true) = self.db.is_excluded(&file.session_id).await {
                self.skip_cache.insert(path_str.clone(), file_mtime);
                skipped += 1;
                continue;
            }

            // Try incremental parse first
            let incremental_result = self.try_incremental(&file, file_size, file_mtime).await;
            if let Some(Ok(())) = incremental_result {
                synced += 1;
                self.skip_cache.remove(&path_str);
                continue;
            }

            // Full parse
            match parser::parse_claude_session(
                &file.path,
                &file.project,
                "local",
            ) {
                Ok(results) => {
                    if results.is_empty() {
                        self.skip_cache.insert(path_str, file_mtime);
                        continue;
                    }

                    for result in &results {
                        if let Err(e) = self.write_result(result).await {
                            eprintln!(
                                "[wsb] sync: write error for {}: {}",
                                result.session.id, e
                            );
                            failed += 1;
                            continue;
                        }
                    }
                    synced += results.len();
                    self.skip_cache.remove(&path_str);
                }
                Err(e) => {
                    eprintln!(
                        "[wsb] sync: parse error {}: {}",
                        file.path.display(),
                        e
                    );
                    self.skip_cache.insert(path_str, file_mtime);
                    failed += 1;
                }
            }
        }

        // Link subagent sessions after all writes
        if let Err(e) = self.db.link_subagent_sessions().await {
            eprintln!("[wsb] sync: link subagent sessions: {}", e);
        }

        // Persist skip cache
        if let Err(e) = self.db.persist_skip_cache(&self.skip_cache).await {
            eprintln!("[wsb] sync: persist skip cache: {}", e);
        }

        let stats = SyncStats {
            synced,
            failed,
            skipped,
            total,
            aborted: false,
        };
        self.last_sync_stats = Some(stats.clone());
        Ok(stats)
    }

    /// Full resync: re-parse everything, preserving user data.
    /// For now this is implemented as a sync_incremental with the skip cache cleared.
    /// A production-grade implementation would use the atomic DB swap pattern
    /// from agentsview's ResyncAll.
    pub async fn sync_full(&mut self) -> Result<SyncStats> {
        self.skip_cache.clear();
        // Clear DB skip cache too
        if let Err(e) = self
            .db
            .persist_skip_cache(&HashMap::new())
            .await
        {
            eprintln!("[wsb] resync: clear skip cache: {}", e);
        }
        self.sync_incremental().await
    }

    /// Try incremental parse for a file that has grown since last sync.
    async fn try_incremental(
        &self,
        file: &DiscoveredFile,
        current_size: i64,
        current_mtime: i64,
    ) -> Option<Result<()>> {
        // Check if we have a stored file size for this session
        let (stored_size, _stored_mtime) = match self
            .db
            .get_session_file_info(&file.session_id)
            .await
        {
            Ok(Some(info)) => info,
            _ => return None,
        };

        // Only try incremental if file has grown
        if current_size <= stored_size {
            return None;
        }

        // Must have existing messages
        let max_ordinal = match self.db.max_ordinal(&file.session_id).await {
            Ok(Some(ord)) => ord,
            _ => return None,
        };

        // Check this file maps to exactly one session (no forks)
        let session_count = match self
            .db
            .count_sessions_for_path(&file.path.to_string_lossy())
            .await
        {
            Ok(count) => count,
            Err(_) => return None,
        };
        if session_count != 1 {
            return None; // Fork detected, needs full parse
        }

        // Parse only new bytes
        match parser::parse_claude_session_from(
            &file.path,
            stored_size,
            max_ordinal + 1,
            &file.session_id,
        ) {
            Ok((new_msgs, ended_at, bytes_consumed)) => {
                if new_msgs.is_empty() && bytes_consumed > 0 {
                    // Non-message events, just advance file size
                    if let Err(e) = self
                        .db
                        .update_session_incremental(
                            &file.session_id,
                            ended_at.as_deref(),
                            // Keep existing counts
                            -1, // sentinel: handler should use existing
                            -1,
                            stored_size + bytes_consumed,
                            current_mtime,
                            0,
                            0,
                        )
                        .await
                    {
                        return Some(Err(e));
                    }
                    return Some(Ok(()));
                }

                if new_msgs.is_empty() {
                    return None; // Nothing new, skip
                }

                // Insert new messages
                if let Err(e) = self.db.insert_messages(&new_msgs).await {
                    return Some(Err(e));
                }

                // Compute new totals
                let mut total_output: i64 = 0;
                let mut peak_context: i64 = 0;
                let mut new_user_count: i64 = 0;
                for msg in &new_msgs {
                    total_output += msg.output_tokens;
                    if msg.context_tokens > peak_context {
                        peak_context = msg.context_tokens;
                    }
                    if msg.role == "user" {
                        new_user_count += 1;
                    }
                }

                // Get existing user count from DB to compute accurate total
                let existing_user_count = self
                    .db
                    .get_session_user_count(&file.session_id)
                    .await
                    .unwrap_or(0);

                let new_offset = stored_size + bytes_consumed;
                if let Err(e) = self
                    .db
                    .update_session_incremental(
                        &file.session_id,
                        ended_at.as_deref(),
                        max_ordinal + 1 + new_msgs.len() as i64,
                        existing_user_count + new_user_count,
                        new_offset,
                        current_mtime,
                        total_output,
                        peak_context,
                    )
                    .await
                {
                    return Some(Err(e));
                }

                Some(Ok(()))
            }
            Err(e) => {
                // DAG fork detected or other error — fall through to full parse
                eprintln!(
                    "[wsb] incremental parse failed for {}: {} (falling back to full)",
                    file.session_id, e
                );
                None
            }
        }
    }

    /// Write a single parse result to the database.
    async fn write_result(&self, result: &ParseResult) -> Result<()> {
        self.db.upsert_session(&result.session).await?;
        if !result.messages.is_empty() {
            self.db.insert_messages(&result.messages).await?;
        }
        Ok(())
    }
}

/// Start the file watcher that triggers incremental sync on changes.
/// Uses notify crate with 500ms debounce.
pub async fn start_watcher(state: WebState) -> Result<()> {
    use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
    use std::time::Duration;

    let home = dirs::home_dir().context("cannot determine home directory")?;
    let watch_dir = home.join(".claude").join("projects");

    if !watch_dir.exists() {
        eprintln!(
            "[wsb] watch dir {} does not exist, file watcher disabled",
            watch_dir.display()
        );
        return Ok(());
    }

    let (tx, mut rx) = tokio::sync::mpsc::channel(256);
    let runtime_handle = tokio::runtime::Handle::current();

    // Spawn the blocking notify watcher in a separate thread
    let watch_dir_clone = watch_dir.clone();
    std::thread::spawn(move || {
        let rt = runtime_handle;
        let mut debouncer = new_debouncer(
            Duration::from_millis(500),
            move |events: Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>| {
                if let Ok(events) = events {
                    let paths: Vec<String> = events
                        .into_iter()
                        .filter(|e| e.kind == DebouncedEventKind::Any)
                        .filter_map(|e| {
                            let path = e.path;
                            if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
                                Some(path.to_string_lossy().to_string())
                            } else {
                                None
                            }
                        })
                        .collect();

                    if !paths.is_empty() {
                        let tx = tx.clone();
                        rt.spawn(async move {
                            let _ = tx.send(paths).await;
                        });
                    }
                }
            },
        )
        .expect("failed to create file watcher");

        debouncer
            .watcher()
            .watch(
                &watch_dir_clone,
                notify::RecursiveMode::Recursive,
            )
            .expect("failed to watch directory");

        // Keep the watcher alive
        loop {
            std::thread::sleep(Duration::from_secs(3600));
        }
    });

    // Process file change events
    while let Some(_changed_paths) = rx.recv().await {
        let engine = state.sync_engine.clone();
        let mut eng = engine.lock().await;
        if let Err(e) = eng.sync_incremental().await {
            eprintln!("[wsb] watcher sync failed: {}", e);
        }
    }

    Ok(())
}