1use std::collections::{HashMap, HashSet};
33use std::path::{Path, PathBuf};
34
35use rusqlite::{Connection, OpenFlags};
36
37use crate::backend::agent_session::{
38 agent_current_zone, is_valid_definition_id, OUTPUT_FILE, SNAPSHOT_FILE,
39};
40use crate::backend::storage::filestore::{FileMeta, FileOpts, FileStore};
41
42const MARKER: &str = ".transcripts_backfilled";
43
44const BACKFILL_VERSION: u32 = 1;
47
48#[derive(Debug, Default, Clone, Copy)]
49pub struct BackfillStats {
50 pub agents_seen: usize,
51 pub data_dirs_scanned: usize,
52 pub seeded: usize,
53 pub skipped_no_source: usize,
54 pub skipped_global_richer: usize,
55}
56
57pub fn backfill_transcripts_once(
61 home: &Path,
62 transcripts_dir: &Path,
63 def_ids: &[String],
64 global: &FileStore,
65) -> BackfillStats {
66 let mut stats = BackfillStats::default();
67 let marker = transcripts_dir.join(MARKER);
68 if marker_version(&marker) >= BACKFILL_VERSION {
69 return stats;
70 }
71
72 let want: HashSet<&str> = def_ids
73 .iter()
74 .map(|s| s.as_str())
75 .filter(|s| is_valid_definition_id(s))
76 .collect();
77 if want.is_empty() {
78 let _ = write_marker(&marker);
79 return stats;
80 }
81
82 let mut best: HashMap<String, Vec<u8>> = HashMap::new();
84 for data in collect_data_dirs(home) {
85 let objdb = data.join("db").join("objects.db");
86 let fdb = data.join("db").join("filestore.db");
87 if !objdb.is_file() || !fdb.is_file() {
88 continue;
89 }
90 stats.data_dirs_scanned += 1;
91 let blocks = match read_agent_blocks(&objdb, &want) {
92 Ok(b) if !b.is_empty() => b,
93 _ => continue,
94 };
95 let conn = match Connection::open_with_flags(&fdb, OpenFlags::SQLITE_OPEN_READ_ONLY) {
96 Ok(c) => c,
97 Err(_) => continue,
98 };
99 for (def_id, block_oid) in blocks {
100 if let Some(bytes) = read_zone_output(&conn, &block_oid) {
101 let better = best.get(&def_id).map(|b| bytes.len() > b.len()).unwrap_or(true);
102 if better {
103 best.insert(def_id, bytes);
104 }
105 }
106 }
107 }
108
109 for def_id in def_ids {
110 if !is_valid_definition_id(def_id) {
111 continue;
112 }
113 stats.agents_seen += 1;
114 let Some(bytes) = best.get(def_id) else {
115 stats.skipped_no_source += 1;
116 continue;
117 };
118 let zone = agent_current_zone(def_id);
119 let cur = global
122 .stat(&zone, OUTPUT_FILE)
123 .ok()
124 .flatten()
125 .map(|f| f.size)
126 .unwrap_or(0);
127 if (bytes.len() as i64) <= cur {
128 stats.skipped_global_richer += 1;
129 continue;
130 }
131 match seed_global_zone(global, &zone, bytes) {
132 Ok(()) => stats.seeded += 1,
133 Err(e) => tracing::warn!(zone = %zone, error = %e, "transcript backfill: seed failed"),
134 }
135 }
136
137 let _ = write_marker(&marker);
138 stats
139}
140
141fn seed_global_zone(global: &FileStore, zone: &str, bytes: &[u8]) -> Result<(), String> {
144 overwrite_zone_file(global, zone, OUTPUT_FILE, bytes)?;
145 let hwm = count_nonblank_lines(bytes);
146 let overlay = serde_json::json!({
147 "schemaVersion": 2,
148 "savedAt": "",
149 "highWaterMark": hwm,
150 "sourceBlockId": "",
151 "documentState": {
152 "collapsedNodeIds": [],
153 "pinnedNodeIds": [],
154 "scrollPosition": 0,
155 "filter": {
156 "showThinking": false,
157 "showSuccessfulTools": true,
158 "showFailedTools": true,
159 "showIncoming": true,
160 "showOutgoing": true
161 }
162 },
163 "paneState": { "detailsOpen": false }
164 });
165 let overlay_bytes = serde_json::to_vec(&overlay).map_err(|e| format!("overlay json: {e}"))?;
166 overwrite_zone_file(global, zone, SNAPSHOT_FILE, &overlay_bytes)?;
167 Ok(())
168}
169
170fn overwrite_zone_file(fs: &FileStore, zone: &str, name: &str, bytes: &[u8]) -> Result<(), String> {
173 use crate::backend::storage::error::StoreError;
174 if fs.stat(zone, name).map_err(|e| format!("stat: {e}"))?.is_none() {
175 match fs.make_file(zone, name, FileMeta::default(), FileOpts::default()) {
176 Ok(()) => {}
177 Err(StoreError::AlreadyExists) => {}
178 Err(e) => return Err(format!("make_file: {e}")),
179 }
180 }
181 fs.write_file(zone, name, bytes).map_err(|e| format!("write_file: {e}"))
182}
183
184fn count_nonblank_lines(bytes: &[u8]) -> u64 {
185 String::from_utf8_lossy(bytes)
186 .lines()
187 .filter(|l| !l.trim().is_empty())
188 .count() as u64
189}
190
191fn read_agent_blocks(
194 objdb: &Path,
195 want: &HashSet<&str>,
196) -> Result<Vec<(String, String)>, rusqlite::Error> {
197 let conn = Connection::open_with_flags(objdb, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
198 let mut stmt = conn.prepare("SELECT data FROM db_block")?;
199 let rows = stmt.query_map([], |row| row.get::<_, Vec<u8>>(0))?;
203 let mut out = Vec::new();
204 for r in rows {
205 let Ok(bytes) = r else { continue };
206 let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) else { continue };
207 let meta = v.get("meta");
208 let agent_id = meta
215 .and_then(|m| m.get("agentId"))
216 .and_then(|x| x.as_str())
217 .or_else(|| meta.and_then(|m| m.get("agent:id")).and_then(|x| x.as_str()));
218 let oid = v.get("oid").and_then(|x| x.as_str());
219 if let (Some(aid), Some(oid)) = (agent_id, oid) {
220 if want.contains(aid) {
221 out.push((aid.to_string(), oid.to_string()));
222 }
223 }
224 }
225 Ok(out)
226}
227
228fn read_zone_output(conn: &Connection, block_oid: &str) -> Option<Vec<u8>> {
233 let size: Option<i64> = conn
234 .query_row(
235 "SELECT size FROM db_wave_file WHERE zoneid = ?1 AND name = ?2",
236 rusqlite::params![block_oid, OUTPUT_FILE],
237 |r| r.get(0),
238 )
239 .ok();
240 if size.unwrap_or(0) <= 0 {
241 return None;
242 }
243 let mut stmt = conn
244 .prepare("SELECT data FROM db_file_data WHERE zoneid = ?1 AND name = ?2 ORDER BY partidx")
245 .ok()?;
246 let rows = stmt
247 .query_map(rusqlite::params![block_oid, OUTPUT_FILE], |r| {
248 r.get::<_, Vec<u8>>(0)
249 })
250 .ok()?;
251 let mut buf = Vec::new();
252 for r in rows {
253 if let Ok(part) = r {
254 buf.extend_from_slice(&part);
255 }
256 }
257 if buf.is_empty() {
258 None
259 } else {
260 Some(buf)
261 }
262}
263
264fn collect_data_dirs(home: &Path) -> Vec<PathBuf> {
267 let mut dirs = Vec::new();
268 let mut add = |d: PathBuf| {
269 if d.join("db").join("objects.db").is_file() {
270 dirs.push(d);
271 }
272 };
273 for ch in dir_subdirs(&home.join("channels")) {
274 for v in dir_subdirs(&ch.join("versions")) {
275 add(v.join("data"));
276 }
277 }
278 for br in dir_subdirs(&home.join("dev")) {
279 add(br.join("data"));
280 for sub in dir_subdirs(&br) {
281 add(sub.join("data"));
282 }
283 }
284 dirs
285}
286
287fn dir_subdirs(p: &Path) -> Vec<PathBuf> {
288 let mut out = Vec::new();
289 if let Ok(rd) = std::fs::read_dir(p) {
290 for e in rd.flatten() {
291 let path = e.path();
292 if path.is_dir() {
293 out.push(path);
294 }
295 }
296 }
297 out
298}
299
300fn marker_version(marker: &Path) -> u32 {
301 std::fs::read_to_string(marker)
302 .ok()
303 .and_then(|s| s.trim().parse::<u32>().ok())
304 .unwrap_or(0)
305}
306
307fn write_marker(marker: &Path) -> std::io::Result<()> {
308 if let Some(parent) = marker.parent() {
309 let _ = std::fs::create_dir_all(parent);
310 }
311 std::fs::write(marker, BACKFILL_VERSION.to_string())
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use crate::backend::agent_session::OUTPUT_FILE as OUT;
318 use std::sync::Arc;
319
320 fn seed_channel(home: &Path, ch: &str, def_id: &str, block_oid: &str, output: &[u8]) {
323 seed_channel_keyed(home, ch, "agentId", def_id, block_oid, output);
324 }
325
326 fn seed_channel_keyed(
327 home: &Path,
328 ch: &str,
329 meta_key: &str,
330 def_id: &str,
331 block_oid: &str,
332 output: &[u8],
333 ) {
334 let data = home
335 .join("channels")
336 .join(ch)
337 .join("versions")
338 .join("0.44.1")
339 .join("data");
340 let db = data.join("db");
341 std::fs::create_dir_all(&db).unwrap();
342 let oc = Connection::open(db.join("objects.db")).unwrap();
344 oc.execute("CREATE TABLE db_block (data TEXT)", []).unwrap();
345 let block_json = serde_json::json!({"oid": block_oid, "meta": {"view":"agent", meta_key: def_id}});
346 oc.execute(
349 "INSERT INTO db_block (data) VALUES (?1)",
350 rusqlite::params![block_json.to_string().into_bytes()],
351 )
352 .unwrap();
353 drop(oc);
354 let fs = FileStore::open(&db.join("filestore.db")).unwrap();
356 fs.make_file(block_oid, OUT, FileMeta::default(), FileOpts::default()).unwrap();
357 fs.append_data(block_oid, OUT, output).unwrap();
358 }
359
360 #[test]
361 fn backfill_seeds_global_zone_from_richest_channel_block() {
362 let tmp = tempfile::tempdir().unwrap();
363 let home = tmp.path();
364 let tdir = home.join("shared").join("agents").join("transcripts");
365 std::fs::create_dir_all(&tdir).unwrap();
366 let def_id = "11111111-2222-3333-4444-555555555555";
367
368 let small = b"{\"type\":\"system\"}\n";
370 let big = b"{\"type\":\"system\"}\n{\"type\":\"assistant\"}\n{\"type\":\"result\"}\n";
371 seed_channel(home, "verify-a", def_id, "blk-small", small);
372 seed_channel(home, "verify-b", def_id, "blk-big", big);
373
374 let global = Arc::new(FileStore::open(&tdir.join("filestore.db")).unwrap());
375 let stats = backfill_transcripts_once(home, &tdir, &[def_id.to_string()], &global);
376
377 assert_eq!(stats.seeded, 1, "should seed the agent zone");
378 let zone = agent_current_zone(def_id);
379 let out = global.read_file(&zone, OUT).unwrap().unwrap();
380 assert_eq!(out, big, "global zone seeded with the RICHEST channel output");
381 let snap = global.read_file(&zone, SNAPSHOT_FILE).unwrap().unwrap();
383 let o: serde_json::Value = serde_json::from_slice(&snap).unwrap();
384 assert_eq!(o["schemaVersion"], 2);
385 assert_eq!(o["highWaterMark"], 3);
386 assert_eq!(o["sourceBlockId"], "");
387
388 let stats2 = backfill_transcripts_once(home, &tdir, &[def_id.to_string()], &global);
390 assert_eq!(stats2.seeded, 0);
391 assert_eq!(stats2.data_dirs_scanned, 0, "marker short-circuits the re-run");
392 }
393
394 #[test]
395 fn backfill_matches_legacy_agent_id_key() {
396 let tmp = tempfile::tempdir().unwrap();
399 let home = tmp.path();
400 let tdir = home.join("shared").join("agents").join("transcripts");
401 std::fs::create_dir_all(&tdir).unwrap();
402 let def_id = "deadbeef-0000-1111-2222-333344445555";
403 seed_channel_keyed(home, "legacy-ch", "agent:id", def_id, "blk-legacy", b"{\"type\":\"system\"}\n{\"type\":\"result\"}\n");
404
405 let global = Arc::new(FileStore::open(&tdir.join("filestore.db")).unwrap());
406 let stats = backfill_transcripts_once(home, &tdir, &[def_id.to_string()], &global);
407 assert_eq!(stats.seeded, 1, "legacy agent:id block must be recovered");
408 let out = global.read_file(&agent_current_zone(def_id), OUT).unwrap().unwrap();
409 assert!(out.starts_with(b"{\"type\":\"system\"}"));
410 }
411
412 #[test]
413 fn backfill_does_not_clobber_richer_global_zone() {
414 let tmp = tempfile::tempdir().unwrap();
415 let home = tmp.path();
416 let tdir = home.join("shared").join("agents").join("transcripts");
417 std::fs::create_dir_all(&tdir).unwrap();
418 let def_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
419 seed_channel(home, "verify-a", def_id, "blk1", b"{\"type\":\"system\"}\n");
420
421 let global = Arc::new(FileStore::open(&tdir.join("filestore.db")).unwrap());
423 let zone = agent_current_zone(def_id);
424 global.make_file(&zone, OUT, FileMeta::default(), FileOpts::default()).unwrap();
425 global.write_file(&zone, OUT, b"{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n{\"d\":4}\n").unwrap();
426
427 let stats = backfill_transcripts_once(home, &tdir, &[def_id.to_string()], &global);
428 assert_eq!(stats.seeded, 0);
429 assert_eq!(stats.skipped_global_richer, 1);
430 let out = global.read_file(&zone, OUT).unwrap().unwrap();
431 assert!(out.starts_with(b"{\"a\":1}"), "richer global content preserved");
432 }
433}