1use std::collections::{HashMap, HashSet};
36use std::path::{Path, PathBuf};
37
38use async_stream::stream;
39use chrono::{DateTime, Utc};
40use rusqlite::{Connection, OptionalExtension};
41use serde_json::{Value, json};
42use tokio::sync::mpsc;
43
44use crate::{
45 sessions::IngestEvent,
46 wire::{FileData, Message, Part, PartKind, Provenance, ProviderOptions, Session},
47};
48
49use super::{
50 Adapter, AdapterError, AdapterFactory, AdapterYield, AdapterYieldStream, DiscoverFuture, Env,
51 RestoreFidelity, RestoredFile, SkipOracle, SkipReason, by_timestamp_then_id, compact_json,
52 config_path,
53 extract::{Extracted, extract_str, json_or_string},
54 jsonl::{RECORD_CAP, parse_bounded},
55 part_id, part_ordinal, raw_record, source_options,
56 sqlite::{self, CHANNEL_CAP, emit},
57 validate_path_id,
58};
59
60const NAME: &str = "opencode";
61
62pub struct OpencodeFactory;
65
66impl AdapterFactory for OpencodeFactory {
67 fn name(&self) -> &'static str {
68 NAME
69 }
70
71 fn open(&self, config: Value) -> Result<Box<dyn Adapter>, AdapterError> {
72 Ok(Box::new(OpencodeAdapter::new(config_path(NAME, config)?)))
73 }
74
75 fn probe_default(&self, env: &Env) -> Option<Value> {
76 let data_dir = env.home.join(".local").join("share").join("opencode");
81 if !data_dir.exists() {
82 return None;
83 }
84 let has_db = db_paths(&data_dir).is_ok_and(|dbs| !dbs.is_empty());
85 let has_tree = data_dir.join("storage").is_dir();
86 (has_db || has_tree).then(|| json!({ "path": data_dir }))
87 }
88
89 fn serialize(
90 &self,
91 session: &crate::sessions::SessionWithMessages,
92 fidelity: RestoreFidelity,
93 ) -> Result<Vec<RestoredFile>, AdapterError> {
94 match fidelity {
95 RestoreFidelity::Native => serialize_native(session),
96 RestoreFidelity::Foreign => serialize_foreign(session),
97 }
98 }
99}
100
101#[derive(Debug, Clone)]
104pub struct OpencodeAdapter {
105 root: PathBuf,
106}
107
108impl OpencodeAdapter {
109 pub fn new(root: impl Into<PathBuf>) -> Self {
110 let root = root.into();
111 let root = if root.file_name().and_then(|name| name.to_str()) == Some("storage") {
115 root.parent()
116 .map_or_else(|| root.clone(), Path::to_path_buf)
117 } else {
118 root
119 };
120 Self { root }
121 }
122}
123
124fn tree_base(root: &Path) -> PathBuf {
129 let nested = root.join("storage");
130 if nested.join("session").is_dir() {
131 nested
132 } else {
133 root.to_path_buf()
134 }
135}
136
137impl Adapter for OpencodeAdapter {
138 fn discover(&self) -> DiscoverFuture<'_> {
139 let root = self.root.clone();
140 Box::pin(async move {
141 tokio::task::spawn_blocking(move || {
142 Ok(enumerate_and_peek(&root, false).entries.len())
147 })
148 .await
149 .map_err(join_error)?
150 })
151 }
152
153 fn plan<'a>(&'a self, oracle: &'a dyn SkipOracle) -> super::PlanFuture<'a> {
154 let root = self.root.clone();
155 Box::pin(async move {
156 let peek = !oracle.is_empty();
164 let peeked = tokio::task::spawn_blocking(move || enumerate_and_peek(&root, peek))
165 .await
166 .map_err(join_error)?;
167 if !peek {
168 return Ok(Some(super::SyncPlan::all_pending(peeked.entries.len())));
169 }
170 Ok(Some(super::SyncPlan::from_heads(
171 oracle,
172 peeked.entries.iter().map(|entry| {
173 let watermark = match entry.source_ts {
174 Some(ts) => super::SourceWatermark::At(ts),
175 None => super::SourceWatermark::Opaque,
176 };
177 (Some(entry.source.session_id()), watermark)
178 }),
179 )))
180 })
181 }
182
183 fn events_with<'a>(&'a self, oracle: &'a dyn SkipOracle) -> AdapterYieldStream<'a> {
184 let adapter = self.clone();
185 Box::pin(stream! {
186 let root = adapter.root.clone();
187 let peek = !oracle.is_empty();
188 let peeked = tokio::task::spawn_blocking(move || enumerate_and_peek(&root, peek)).await;
194 let Peeked { tree_base, entries, duplicates, errors } = match peeked {
195 Ok(peeked) => peeked,
196 Err(join) => { yield Err(join_error(join)); return; }
197 };
198
199 for error in errors {
205 yield Err(error);
206 }
207
208 if duplicates > 0 {
215 yield Ok(AdapterYield::SkippedBatch {
216 reason: SkipReason::Superseded,
217 count: duplicates,
218 });
219 }
220
221 let mut survivors = Vec::with_capacity(entries.len());
222 for entry in entries {
223 if crate::adapter::is_session_fresh(oracle, entry.source.session_id(), entry.source_ts) {
224 yield Ok(AdapterYield::Skipped {
225 session_id: Some(entry.source.session_id().to_owned()),
226 project: None,
227 reason: SkipReason::Fresh,
228 });
229 continue;
230 }
231 survivors.push(entry.source);
232 }
233
234 let (tx, mut rx) = mpsc::channel(CHANNEL_CAP);
235 let handle =
236 tokio::task::spawn_blocking(move || read_survivors(&tree_base, survivors, &tx));
237 while let Some(item) = rx.recv().await {
238 yield item;
239 }
240 if let Err(join) = handle.await {
241 yield Err(join_error(join));
242 }
243 })
244 }
245}
246
247pub(crate) struct Attribution {
254 pub source_agent_root: String,
255 pub project: Extracted<String>,
256 pub extra_options: (String, Value),
257}
258
259pub(crate) fn composed_events_with<'a>(
266 root: PathBuf,
267 oracle: &'a dyn SkipOracle,
268 attribution: Attribution,
269) -> AdapterYieldStream<'a> {
270 use tokio_stream::StreamExt;
271 Box::pin(stream! {
272 let adapter = OpencodeAdapter::new(root);
273 let mut inner = adapter.events_with(oracle);
274 while let Some(item) = inner.next().await {
275 yield item.map(|yielded| reattribute(yielded, &attribution));
276 }
277 })
278}
279
280fn reattribute(yielded: AdapterYield, attribution: &Attribution) -> AdapterYield {
284 let AdapterYield::Event(IngestEvent::Session(mut session)) = yielded else {
285 return yielded;
286 };
287 session.source_agent = if session.source_agent == NAME {
288 attribution.source_agent_root.clone()
289 } else {
290 format!("{}/subagent", attribution.source_agent_root)
291 };
292 session.project = attribution.project.clone();
293 let (key, value) = &attribution.extra_options;
294 session.options.insert(key.clone(), value.clone());
295 AdapterYield::Event(IngestEvent::Session(session))
296}
297
298struct HeadEntry {
301 source: SessionSource,
302 source_ts: Option<i64>,
303}
304
305enum SessionSource {
308 Db(DbLight),
309 Tree(SessionFile),
310}
311
312impl SessionSource {
313 fn session_id(&self) -> &str {
314 match self {
315 SessionSource::Db(light) => &light.session_id,
316 SessionSource::Tree(file) => &file.session_id,
317 }
318 }
319}
320
321struct DbLight {
325 db_path: PathBuf,
326 session_id: String,
327}
328
329struct DbSessionHead {
332 db_path: PathBuf,
333 session: Session,
334}
335
336struct Peeked {
340 tree_base: PathBuf,
341 entries: Vec<HeadEntry>,
342 duplicates: usize,
344 errors: Vec<AdapterError>,
345}
346
347fn enumerate_and_peek(root: &Path, peek: bool) -> Peeked {
362 let tree_base = tree_base(root);
363 let mut errors = Vec::new();
364 let mut conns: HashMap<PathBuf, Connection> = HashMap::new();
365
366 let mut db_entries: Vec<DbLight> = Vec::new();
368 let mut db_ids: HashSet<String> = HashSet::new();
369 let paths = match db_paths(root) {
370 Ok(paths) => paths,
371 Err(error) => {
372 tracing::warn!(path = %root.display(), %error, "opencode: listing DB files failed");
373 errors.push(error);
374 Vec::new()
375 }
376 };
377 for db_path in paths {
378 match db_session_ids(&mut conns, &db_path) {
379 Ok(ids) => {
380 for id in ids {
381 db_ids.insert(id.clone());
382 db_entries.push(DbLight {
383 db_path: db_path.clone(),
384 session_id: id,
385 });
386 }
387 }
388 Err(error) => {
389 tracing::warn!(
390 path = %db_path.display(),
391 %error,
392 "opencode: enumerating DB sessions failed"
393 );
394 errors.push(error);
395 }
396 }
397 }
398
399 let mut tree_entries: Vec<SessionFile> = Vec::new();
401 let mut duplicates = 0usize;
402 match collect_session_files(&tree_base) {
403 Ok(files) => {
404 for file in files {
405 if db_ids.contains(&file.session_id) {
406 duplicates += 1;
407 } else {
408 tree_entries.push(file);
409 }
410 }
411 }
412 Err(error) => {
413 tracing::warn!(
414 path = %tree_base.display(),
415 %error,
416 "opencode: listing tree sessions failed"
417 );
418 errors.push(error);
419 }
420 }
421
422 let mut entries = Vec::with_capacity(db_entries.len() + tree_entries.len());
423 for light in db_entries {
424 let source_ts = if peek {
425 match connection(&mut conns, &light.db_path) {
426 Ok(conn) => {
427 db_session_watermark(conn, &light.db_path, &light.session_id).unwrap_or(None)
428 }
429 Err(_) => None,
430 }
431 } else {
432 None
433 };
434 entries.push(HeadEntry {
435 source: SessionSource::Db(light),
436 source_ts,
437 });
438 }
439 for mut file in tree_entries {
440 let source_ts = if peek {
441 match walk_session_subtree(&tree_base, &file.session_id) {
442 Ok(walk) => {
443 let ts = newest_message_ts(&walk);
444 file.cached_subtree = Some(walk);
445 ts
446 }
447 Err(_) => None,
448 }
449 } else {
450 None
451 };
452 entries.push(HeadEntry {
453 source: SessionSource::Tree(file),
454 source_ts,
455 });
456 }
457
458 Peeked {
459 tree_base,
460 entries,
461 duplicates,
462 errors,
463 }
464}
465
466fn read_survivors(
472 tree_base: &Path,
473 survivors: Vec<SessionSource>,
474 tx: &mpsc::Sender<Result<AdapterYield, AdapterError>>,
475) {
476 let mut conns: HashMap<PathBuf, Connection> = HashMap::new();
477 for source in survivors {
478 let keep = match source {
479 SessionSource::Db(light) => read_db_survivor(&mut conns, light, tx),
480 SessionSource::Tree(file) => read_one_session(tree_base, file, tx),
481 };
482 if !keep {
483 return;
484 }
485 }
486}
487
488fn read_db_survivor(
494 conns: &mut HashMap<PathBuf, Connection>,
495 light: DbLight,
496 tx: &mpsc::Sender<Result<AdapterYield, AdapterError>>,
497) -> bool {
498 if let Err(error) = validate_path_id(
501 NAME,
502 "session id",
503 &light.session_id,
504 record_location(&light.db_path, &light.session_id, &light.session_id),
505 ) {
506 return tx.blocking_send(Err(error)).is_ok();
507 }
508 let conn = match connection(conns, &light.db_path) {
509 Ok(conn) => conn,
510 Err(error) => return tx.blocking_send(Err(error)).is_ok(),
511 };
512 match fetch_db_session_head(conn, &light.db_path, &light.session_id) {
513 Ok(Some(head)) => read_db_session(conn, head, tx),
514 Ok(None) => {
515 let error = AdapterError::schema(
516 NAME,
517 record_location(&light.db_path, &light.session_id, &light.session_id),
518 "session row vanished between enumeration and read",
519 );
520 tx.blocking_send(Err(error)).is_ok()
521 }
522 Err(error) => tx.blocking_send(Err(error)).is_ok(),
523 }
524}
525
526fn join_error(join: tokio::task::JoinError) -> AdapterError {
528 sqlite::join_error(NAME, join)
529}
530
531struct SessionFile {
535 session_id: String,
536 path: PathBuf,
537 cached_subtree: Option<SubtreeWalk>,
538}
539
540struct SubtreeWalk {
544 message_files: Vec<PathBuf>,
545 part_files_by_message: Vec<Vec<PathBuf>>,
548}
549
550fn collect_session_files(root: &Path) -> Result<Vec<SessionFile>, AdapterError> {
553 let session_root = root.join("session");
554 let io = |path: &Path, source| AdapterError::io(NAME, path.display().to_string(), source);
555 let entries = match std::fs::read_dir(&session_root) {
556 Ok(entries) => entries,
557 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
558 Err(error) => return Err(io(&session_root, error)),
559 };
560 let mut out = Vec::new();
561 for project in entries {
562 let project = project.map_err(|error| io(&session_root, error))?;
563 if !project
564 .file_type()
565 .map_err(|error| io(&project.path(), error))?
566 .is_dir()
567 {
568 continue;
569 }
570 let project_dir = project.path();
571 for session in std::fs::read_dir(&project_dir).map_err(|error| io(&project_dir, error))? {
572 let session = session.map_err(|error| io(&project_dir, error))?;
573 let path = session.path();
574 if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
575 continue;
576 }
577 let Some(session_id) = path
578 .file_stem()
579 .and_then(|s| s.to_str())
580 .map(ToOwned::to_owned)
581 else {
582 continue;
583 };
584 validate_path_id(
585 NAME,
586 "session file name",
587 &session_id,
588 path.display().to_string(),
589 )?;
590 out.push(SessionFile {
591 session_id,
592 path,
593 cached_subtree: None,
594 });
595 }
596 }
597 out.sort_by(|a, b| a.path.cmp(&b.path));
598 Ok(out)
599}
600
601fn walk_session_subtree(tree_base: &Path, session_id: &str) -> Result<SubtreeWalk, AdapterError> {
605 let message_dir = tree_base.join("message").join(session_id);
606 let message_files = list_json_sorted(&message_dir)?;
607 let mut part_files_by_message = Vec::with_capacity(message_files.len());
608 for message_path in &message_files {
609 let Some(message_id) = message_path.file_stem().and_then(|stem| stem.to_str()) else {
610 part_files_by_message.push(Vec::new());
611 continue;
612 };
613 validate_path_id(
614 NAME,
615 "message file name",
616 message_id,
617 message_path.display().to_string(),
618 )?;
619 let part_dir = tree_base.join("part").join(message_id);
620 let parts = list_json_sorted(&part_dir)?;
621 part_files_by_message.push(parts);
622 }
623 Ok(SubtreeWalk {
624 message_files,
625 part_files_by_message,
626 })
627}
628
629fn newest_message_ts(walk: &SubtreeWalk) -> Option<i64> {
636 let message = read_json(walk.message_files.last()?).ok()?;
637 let parts = walk
638 .part_files_by_message
639 .last()
640 .into_iter()
641 .flatten()
642 .filter_map(|part_path| read_json(part_path).ok());
643 watermark_micros(&message, parts)
644}
645
646fn watermark_micros(message: &Value, parts: impl IntoIterator<Item = Value>) -> Option<i64> {
659 let mut newest = millis_at(message, &["time", "created"])?;
660 for part in parts {
661 if let Some(end) = millis_at(&part, &["state", "time", "end"]) {
662 newest = newest.max(end);
663 }
664 }
665 Some(newest.timestamp_micros())
666}
667
668fn read_one_session(
670 tree_base: &Path,
671 file: SessionFile,
672 tx: &mpsc::Sender<Result<AdapterYield, AdapterError>>,
673) -> bool {
674 let session_value = match read_json(&file.path) {
675 Ok(value) => value,
676 Err(error) => {
677 emit!(tx, Err(error));
678 return true;
679 }
680 };
681 let session = match session_from_value(&session_value, &file.path) {
682 Ok(session) => session,
683 Err(error) => {
684 emit!(tx, Err(error));
685 return true;
686 }
687 };
688 let session_id = session.id.clone();
689 if let Err(error) = validate_path_id(
690 NAME,
691 "session id",
692 &session_id,
693 file.path.display().to_string(),
694 ) {
695 emit!(tx, Err(error));
696 return true;
697 }
698 let session_created_at = session.created_at;
699 emit!(tx, Ok(AdapterYield::Event(IngestEvent::Session(session))));
700
701 let (message_files, mut part_files_by_message) = match file.cached_subtree {
703 Some(walk) => (walk.message_files, walk.part_files_by_message),
704 None => {
705 let message_dir = tree_base.join("message").join(&session_id);
706 let files = match list_json_sorted(&message_dir) {
707 Ok(files) => files,
708 Err(error) => {
709 emit!(tx, Err(error));
710 return true;
711 }
712 };
713 (files, Vec::new())
714 }
715 };
716 let use_cache = !part_files_by_message.is_empty();
717
718 for (index, message_path) in message_files.iter().enumerate() {
719 let message_value = match read_json(message_path) {
720 Ok(value) => value,
721 Err(error) => {
722 emit!(tx, Err(error));
723 continue;
724 }
725 };
726 let Some(message_id) = message_value.get("id").and_then(Value::as_str) else {
727 emit!(
728 tx,
729 Err(AdapterError::schema(
730 NAME,
731 message_path.display().to_string(),
732 "message file missing `id`",
733 ))
734 );
735 continue;
736 };
737 if let Err(error) = validate_path_id(
738 NAME,
739 "message id",
740 message_id,
741 message_path.display().to_string(),
742 ) {
743 emit!(tx, Err(error));
744 continue;
745 }
746 let part_files = if use_cache {
747 std::mem::take(&mut part_files_by_message[index])
748 } else {
749 let part_dir = tree_base.join("part").join(message_id);
750 match list_json_sorted(&part_dir) {
751 Ok(files) => files,
752 Err(error) => {
753 emit!(tx, Err(error));
754 continue;
755 }
756 }
757 };
758 let mut parts = Vec::with_capacity(part_files.len());
759 for part_path in part_files {
760 match read_json(&part_path) {
761 Ok(value) => parts.push(value),
762 Err(error) => emit!(tx, Err(error)),
763 }
764 }
765 match build_message_events(&session_id, &message_value, &parts, session_created_at) {
766 Ok(events) => {
767 for event in events {
768 emit!(tx, Ok(AdapterYield::Event(event)));
769 }
770 }
771 Err(error) => emit!(tx, Err(error)),
772 }
773 }
774 true
775}
776
777const SESSION_COLUMNS: &str = "id, project_id, workspace_id, parent_id, slug, directory, path, \
781 title, version, share_url, summary_additions, summary_deletions, summary_files, \
782 summary_diffs, metadata, cost, tokens_input, tokens_output, tokens_reasoning, \
783 tokens_cache_read, tokens_cache_write, revert, permission, agent, model, time_created, \
784 time_updated, time_compacting, time_archived";
785
786fn list_files_sorted(
790 dir: &Path,
791 extension: &str,
792 name_filter: impl Fn(&str) -> bool,
793) -> Result<Vec<PathBuf>, AdapterError> {
794 let entries = match std::fs::read_dir(dir) {
795 Ok(entries) => entries,
796 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
797 Err(error) => return Err(AdapterError::io(NAME, dir.display().to_string(), error)),
798 };
799 let mut out = Vec::new();
800 for entry in entries {
801 let entry =
802 entry.map_err(|error| AdapterError::io(NAME, dir.display().to_string(), error))?;
803 let path = entry.path();
804 if path.extension().and_then(|ext| ext.to_str()) != Some(extension) {
805 continue;
806 }
807 if path
808 .file_name()
809 .and_then(|name| name.to_str())
810 .is_some_and(&name_filter)
811 {
812 out.push(path);
813 }
814 }
815 out.sort();
816 Ok(out)
817}
818
819fn db_paths(root: &Path) -> Result<Vec<PathBuf>, AdapterError> {
823 list_files_sorted(root, "db", |name| name.starts_with("opencode"))
824}
825
826#[cfg(test)]
828fn open_db(path: &Path) -> Result<Connection, AdapterError> {
829 sqlite::open_db(NAME, path)
830}
831
832fn db_error(path: &Path, op: &str, error: &rusqlite::Error) -> AdapterError {
836 sqlite::db_error(NAME, path, op, error)
837}
838
839fn connection<'a>(
841 conns: &'a mut HashMap<PathBuf, Connection>,
842 path: &Path,
843) -> Result<&'a Connection, AdapterError> {
844 sqlite::connection(NAME, conns, path)
845}
846
847fn db_session_ids(
851 conns: &mut HashMap<PathBuf, Connection>,
852 db_path: &Path,
853) -> Result<Vec<String>, AdapterError> {
854 let conn = connection(conns, db_path)?;
855 let mut stmt = conn
856 .prepare_cached("SELECT id FROM session ORDER BY id")
857 .map_err(|error| db_error(db_path, "prepare session ids", &error))?;
858 let rows = stmt
859 .query_map([], |row| row.get::<_, String>(0))
860 .map_err(|error| db_error(db_path, "query session ids", &error))?;
861 rows.collect::<rusqlite::Result<Vec<_>>>()
862 .map_err(|error| db_error(db_path, "read session id", &error))
863}
864
865fn fetch_db_session_head(
869 conn: &Connection,
870 db_path: &Path,
871 session_id: &str,
872) -> Result<Option<DbSessionHead>, AdapterError> {
873 let mut stmt = conn
874 .prepare_cached(&format!(
875 "SELECT {SESSION_COLUMNS} FROM session WHERE id = ?1"
876 ))
877 .map_err(|error| db_error(db_path, "prepare session by id", &error))?;
878 let info = stmt
879 .query_row([session_id], session_info_from_row)
880 .optional()
881 .map_err(|error| db_error(db_path, "query session by id", &error))?;
882 info.map(|info| db_session_head(db_path, &info)).transpose()
883}
884
885fn db_session_head(db_path: &Path, info: &Value) -> Result<DbSessionHead, AdapterError> {
889 let mut session = session_from_value(info, db_path)?;
890 if info.get("parentID").and_then(Value::as_str).is_some() {
898 session.source_agent = match info.get("agent").and_then(Value::as_str) {
899 Some(agent) => format!("{NAME}/{agent}"),
900 None => format!("{NAME}/subagent"),
901 };
902 }
903 Ok(DbSessionHead {
904 db_path: db_path.to_path_buf(),
905 session,
906 })
907}
908
909fn session_info_from_row(row: &rusqlite::Row) -> rusqlite::Result<Value> {
916 let mut info = serde_json::Map::new();
917 info.insert("id".to_owned(), json!(row.get::<_, String>("id")?));
918 info.insert("slug".to_owned(), json!(row.get::<_, String>("slug")?));
919 info.insert(
920 "projectID".to_owned(),
921 json!(row.get::<_, String>("project_id")?),
922 );
923 if let Some(value) = row.get::<_, Option<String>>("workspace_id")? {
924 info.insert("workspaceID".to_owned(), json!(value));
925 }
926 info.insert(
927 "directory".to_owned(),
928 json!(row.get::<_, String>("directory")?),
929 );
930 if let Some(value) = row.get::<_, Option<String>>("path")? {
931 info.insert("path".to_owned(), json!(value));
932 }
933 if let Some(value) = row.get::<_, Option<String>>("parent_id")? {
934 info.insert("parentID".to_owned(), json!(value));
935 }
936 info.insert("title".to_owned(), json!(row.get::<_, String>("title")?));
937 if let Some(value) = row.get::<_, Option<String>>("agent")? {
938 info.insert("agent".to_owned(), json!(value));
939 }
940 if let Some(value) = row.get::<_, Option<String>>("model")? {
941 info.insert("model".to_owned(), json_or_string(&value));
942 }
943 info.insert(
944 "version".to_owned(),
945 json!(row.get::<_, String>("version")?),
946 );
947 let summary_additions = row.get::<_, Option<i64>>("summary_additions")?;
948 let summary_deletions = row.get::<_, Option<i64>>("summary_deletions")?;
949 let summary_files = row.get::<_, Option<i64>>("summary_files")?;
950 let summary_diffs = row.get::<_, Option<String>>("summary_diffs")?;
951 if summary_additions.is_some() || summary_deletions.is_some() || summary_files.is_some() {
952 let mut summary = serde_json::Map::new();
953 summary.insert(
954 "additions".to_owned(),
955 json!(summary_additions.unwrap_or(0)),
956 );
957 summary.insert(
958 "deletions".to_owned(),
959 json!(summary_deletions.unwrap_or(0)),
960 );
961 summary.insert("files".to_owned(), json!(summary_files.unwrap_or(0)));
962 if let Some(value) = &summary_diffs {
963 summary.insert("diffs".to_owned(), json_or_string(value));
964 }
965 info.insert("summary".to_owned(), Value::Object(summary));
966 }
967 info.insert("cost".to_owned(), json!(row.get::<_, f64>("cost")?));
968 info.insert(
969 "tokens".to_owned(),
970 json!({
971 "input": row.get::<_, i64>("tokens_input")?,
972 "output": row.get::<_, i64>("tokens_output")?,
973 "reasoning": row.get::<_, i64>("tokens_reasoning")?,
974 "cache": {
975 "read": row.get::<_, i64>("tokens_cache_read")?,
976 "write": row.get::<_, i64>("tokens_cache_write")?,
977 },
978 }),
979 );
980 if let Some(value) = row.get::<_, Option<String>>("share_url")? {
981 info.insert("share".to_owned(), json!({ "url": value }));
982 }
983 if let Some(value) = row.get::<_, Option<String>>("metadata")? {
984 info.insert("metadata".to_owned(), json_or_string(&value));
985 }
986 if let Some(value) = row.get::<_, Option<String>>("revert")? {
987 info.insert("revert".to_owned(), json_or_string(&value));
988 }
989 if let Some(value) = row.get::<_, Option<String>>("permission")? {
990 info.insert("permission".to_owned(), json_or_string(&value));
991 }
992 let mut time = serde_json::Map::new();
993 time.insert(
994 "created".to_owned(),
995 json!(row.get::<_, i64>("time_created")?),
996 );
997 time.insert(
998 "updated".to_owned(),
999 json!(row.get::<_, i64>("time_updated")?),
1000 );
1001 if let Some(value) = row.get::<_, Option<i64>>("time_compacting")? {
1002 time.insert("compacting".to_owned(), json!(value));
1003 }
1004 if let Some(value) = row.get::<_, Option<i64>>("time_archived")? {
1005 time.insert("archived".to_owned(), json!(value));
1006 }
1007 info.insert("time".to_owned(), Value::Object(time));
1008 Ok(Value::Object(info))
1009}
1010
1011fn db_session_watermark(
1025 conn: &Connection,
1026 db_path: &Path,
1027 session_id: &str,
1028) -> Result<Option<i64>, AdapterError> {
1029 let mut newest_stmt = conn
1030 .prepare_cached("SELECT id FROM message WHERE session_id = ?1 ORDER BY id DESC LIMIT 1")
1031 .map_err(|error| db_error(db_path, "prepare newest message id", &error))?;
1032 let newest = newest_stmt
1033 .query_row([session_id], |row| row.get::<_, String>(0))
1034 .optional()
1035 .map_err(|error| db_error(db_path, "query newest message id", &error))?;
1036 let Some(message_id) = newest else {
1037 return Ok(None);
1038 };
1039 let mut data_stmt = conn
1040 .prepare_cached("SELECT data FROM message WHERE id = ?1")
1041 .map_err(|error| db_error(db_path, "prepare newest message data", &error))?;
1042 let data = data_stmt
1043 .query_row([&message_id], |row| row.get::<_, String>(0))
1044 .optional()
1045 .map_err(|error| db_error(db_path, "query newest message data", &error))?;
1046 let Some(data) = data else {
1049 return Ok(None);
1050 };
1051 let Ok(message) = serde_json::from_str::<Value>(&data) else {
1052 return Ok(None);
1053 };
1054 let mut stmt = conn
1055 .prepare_cached("SELECT data FROM part WHERE message_id = ?1 ORDER BY id")
1056 .map_err(|error| db_error(db_path, "prepare newest parts", &error))?;
1057 let rows = stmt
1058 .query_map([&message_id], |row| row.get::<_, String>(0))
1059 .map_err(|error| db_error(db_path, "query newest parts", &error))?;
1060 let datas = rows
1061 .collect::<rusqlite::Result<Vec<_>>>()
1062 .map_err(|error| db_error(db_path, "read newest part row", &error))?;
1063 let parts = datas
1064 .iter()
1065 .filter_map(|data| serde_json::from_str::<Value>(data).ok());
1066 Ok(watermark_micros(&message, parts))
1067}
1068
1069struct RecordRow {
1073 id: String,
1074 data: String,
1075}
1076
1077fn fetch_records(
1078 conn: &Connection,
1079 db_path: &Path,
1080 sql: &str,
1081 param: &str,
1082 kind: &str,
1083) -> Result<Vec<RecordRow>, AdapterError> {
1084 let mut stmt = conn
1085 .prepare_cached(sql)
1086 .map_err(|error| db_error(db_path, &format!("prepare {kind}"), &error))?;
1087 let rows = stmt
1088 .query_map([param], |row| {
1089 Ok(RecordRow {
1090 id: row.get(0)?,
1091 data: row.get(1)?,
1092 })
1093 })
1094 .map_err(|error| db_error(db_path, &format!("query {kind}"), &error))?;
1095 rows.collect::<rusqlite::Result<Vec<_>>>()
1096 .map_err(|error| db_error(db_path, &format!("read {kind} row"), &error))
1097}
1098
1099fn read_db_session(
1104 conn: &Connection,
1105 head: DbSessionHead,
1106 tx: &mpsc::Sender<Result<AdapterYield, AdapterError>>,
1107) -> bool {
1108 let db_path = head.db_path.clone();
1109 let session_id = head.session.id.clone();
1110 let session_anchor = head.session.created_at;
1111 emit!(
1112 tx,
1113 Ok(AdapterYield::Event(IngestEvent::Session(head.session)))
1114 );
1115
1116 let mut messages = match fetch_records(
1122 conn,
1123 &db_path,
1124 "SELECT id, data FROM message WHERE session_id = ?1",
1125 &session_id,
1126 "message",
1127 ) {
1128 Ok(rows) => rows,
1129 Err(error) => {
1130 emit!(tx, Err(error));
1131 return true;
1132 }
1133 };
1134 messages.sort_unstable_by(|a, b| a.id.cmp(&b.id));
1135
1136 for message in messages {
1137 let message_id = message.id;
1138 let default_ts = session_anchor;
1147 let message_value = match reconstruct_record(
1148 &message.data,
1149 [("id", json!(message_id)), ("sessionID", json!(session_id))],
1150 &db_path,
1151 &session_id,
1152 &message_id,
1153 ) {
1154 Ok(value) => value,
1155 Err(error) => {
1156 emit!(tx, Err(error));
1157 continue;
1158 }
1159 };
1160
1161 let part_rows = match fetch_records(
1164 conn,
1165 &db_path,
1166 "SELECT id, data FROM part WHERE message_id = ?1 ORDER BY id",
1167 &message_id,
1168 "part",
1169 ) {
1170 Ok(rows) => rows,
1171 Err(error) => {
1172 emit!(tx, Err(error));
1173 continue;
1174 }
1175 };
1176 let mut parts = Vec::with_capacity(part_rows.len());
1177 for part in part_rows {
1178 match reconstruct_record(
1179 &part.data,
1180 [
1181 ("id", json!(part.id)),
1182 ("sessionID", json!(session_id)),
1183 ("messageID", json!(message_id)),
1184 ],
1185 &db_path,
1186 &session_id,
1187 &part.id,
1188 ) {
1189 Ok(value) => parts.push(value),
1190 Err(error) => emit!(tx, Err(error)),
1191 }
1192 }
1193 match build_message_events(&session_id, &message_value, &parts, default_ts) {
1194 Ok(events) => {
1195 for event in events {
1196 emit!(tx, Ok(AdapterYield::Event(event)));
1197 }
1198 }
1199 Err(error) => emit!(tx, Err(error)),
1200 }
1201 }
1202 true
1203}
1204
1205fn reconstruct_record(
1210 data: &str,
1211 inject: impl IntoIterator<Item = (&'static str, Value)>,
1212 db_path: &Path,
1213 session_id: &str,
1214 record_id: &str,
1215) -> Result<Value, AdapterError> {
1216 let mut value = parse_bounded(NAME, data.as_bytes(), || {
1217 record_location(db_path, session_id, record_id)
1218 })?;
1219 if let Value::Object(map) = &mut value {
1220 for (key, injected) in inject {
1221 map.insert(key.to_owned(), injected);
1222 }
1223 }
1224 Ok(value)
1225}
1226
1227fn record_location(db_path: &Path, session_id: &str, record_id: &str) -> String {
1228 format!(
1229 "{}::session={session_id}::record={record_id}",
1230 db_path.display()
1231 )
1232}
1233
1234fn read_json(path: &Path) -> Result<Value, AdapterError> {
1239 let io = |source| AdapterError::io(NAME, path.display().to_string(), source);
1240 let len = std::fs::metadata(path).map_err(io)?.len();
1241 if len > RECORD_CAP as u64 {
1242 return Err(AdapterError::schema(
1243 NAME,
1244 path.display().to_string(),
1245 format!("record data exceeds adapter record cap: {len} bytes > {RECORD_CAP}"),
1246 ));
1247 }
1248 let bytes = std::fs::read(path).map_err(io)?;
1249 parse_bounded(NAME, &bytes, || path.display().to_string())
1250}
1251
1252fn list_json_sorted(dir: &Path) -> Result<Vec<PathBuf>, AdapterError> {
1256 list_files_sorted(dir, "json", |_| true)
1257}
1258
1259fn session_from_value(value: &Value, path: &Path) -> Result<Session, AdapterError> {
1260 let display = path.display().to_string();
1261 let id = value
1262 .get("id")
1263 .and_then(Value::as_str)
1264 .ok_or_else(|| AdapterError::schema(NAME, display.clone(), "session missing `id`"))?
1265 .to_owned();
1266 let created_at = millis_at(value, &["time", "created"]).ok_or_else(|| {
1267 AdapterError::schema(NAME, display.clone(), "session missing `time.created`")
1268 })?;
1269 let project = extract_str(value, "directory")
1272 .ok_or_else(|| AdapterError::schema(NAME, display, "session missing `directory`"))?;
1273
1274 let options = opencode_raw(value);
1275
1276 Ok(Session {
1277 id,
1278 parent_session_id: value
1281 .get("parentID")
1282 .and_then(Value::as_str)
1283 .map(ToOwned::to_owned),
1284 parent_message_id: None,
1285 source_agent: NAME.to_owned(),
1286 created_at,
1287 project,
1288 options,
1289 })
1290}
1291
1292fn build_message_events(
1296 session_id: &str,
1297 message_value: &Value,
1298 part_values: &[Value],
1299 default_timestamp: DateTime<Utc>,
1300) -> Result<Vec<IngestEvent>, AdapterError> {
1301 let message_id = message_value
1302 .get("id")
1303 .and_then(Value::as_str)
1304 .ok_or_else(|| AdapterError::schema(NAME, session_id.to_owned(), "message missing `id`"))?;
1305 let role = message_value.get("role").and_then(Value::as_str);
1306 let timestamp = millis_at(message_value, &["time", "created"]).unwrap_or(default_timestamp);
1307
1308 let options = opencode_raw(message_value);
1309 let message = match role {
1310 Some("user") => Message::User {
1311 id: message_id.to_owned(),
1312 session_id: session_id.to_owned(),
1313 timestamp,
1314 options,
1315 },
1316 Some("assistant") => Message::Assistant {
1317 id: message_id.to_owned(),
1318 session_id: session_id.to_owned(),
1319 timestamp,
1320 options,
1321 },
1322 _ => Message::System {
1327 id: message_id.to_owned(),
1328 session_id: session_id.to_owned(),
1329 timestamp,
1330 content: extract_str(message_value, "role"),
1331 options,
1332 },
1333 };
1334
1335 let mut events = vec![IngestEvent::Message(message)];
1336 let mut deferred = Vec::new();
1337 for (ordinal, part_value) in part_values.iter().enumerate() {
1338 let mapped = map_part(session_id, message_id, ordinal, part_value, timestamp)?;
1339 events.push(IngestEvent::Part(mapped.part));
1340 if let Some(split) = mapped.tool_split {
1341 deferred.push(split);
1342 }
1343 }
1344 for ToolSplit {
1345 message: tool_message,
1346 result,
1347 } in deferred
1348 {
1349 events.push(IngestEvent::Message(tool_message));
1350 events.push(IngestEvent::Part(result));
1351 }
1352 Ok(events)
1353}
1354
1355struct MappedPart {
1358 part: Part,
1359 tool_split: Option<ToolSplit>,
1360}
1361
1362struct ToolSplit {
1363 message: Message,
1364 result: Part,
1365}
1366
1367fn map_part(
1368 session_id: &str,
1369 message_id: &str,
1370 ordinal: usize,
1371 value: &Value,
1372 message_ts: DateTime<Utc>,
1373) -> Result<MappedPart, AdapterError> {
1374 let kind = value.get("type").and_then(Value::as_str);
1375 let id = value
1376 .get("id")
1377 .and_then(Value::as_str)
1378 .ok_or_else(|| AdapterError::schema(NAME, message_id.to_owned(), "part missing `id`"))?
1379 .to_owned();
1380
1381 if kind == Some("tool") {
1382 return Ok(tool_part(
1383 session_id, message_id, &id, ordinal, value, message_ts,
1384 ));
1385 }
1386
1387 let (provenance, part_kind) = match kind {
1388 Some("text") => (text_provenance(value), text_kind(value)),
1389 Some("reasoning") => (Provenance::Conversational, reasoning_kind(value)),
1390 Some("file") => (Provenance::Conversational, file_kind(value)),
1391 _ => (Provenance::Injected, PartKind::Text { text: None }),
1395 };
1396
1397 Ok(MappedPart {
1398 part: Part {
1399 session_id: session_id.to_owned(),
1400 id,
1401 message_id: message_id.to_owned(),
1402 ordinal: part_ordinal(ordinal),
1403 provenance,
1404 options: opencode_raw(value),
1405 kind: part_kind,
1406 },
1407 tool_split: None,
1408 })
1409}
1410
1411fn text_provenance(value: &Value) -> Provenance {
1415 if value.get("synthetic").and_then(Value::as_bool) == Some(true) {
1416 Provenance::Injected
1417 } else {
1418 Provenance::Conversational
1419 }
1420}
1421
1422fn text_kind(value: &Value) -> PartKind {
1423 PartKind::Text {
1424 text: extract_str(value, "text"),
1425 }
1426}
1427
1428fn reasoning_kind(value: &Value) -> PartKind {
1429 PartKind::Reasoning {
1430 text: extract_str(value, "text"),
1431 }
1432}
1433
1434fn file_kind(value: &Value) -> PartKind {
1435 let media_type = value
1438 .get("mime")
1439 .and_then(Value::as_str)
1440 .map(ToOwned::to_owned);
1441 let file_name = value
1442 .get("filename")
1443 .and_then(Value::as_str)
1444 .map(ToOwned::to_owned);
1445 let data = match value.get("url").and_then(Value::as_str) {
1446 Some(url) => FileData::Url(url.to_owned()),
1447 None => FileData::String(compact_json(value)),
1448 };
1449 PartKind::File {
1450 media_type,
1451 file_name,
1452 data,
1453 }
1454}
1455
1456fn tool_part(
1462 session_id: &str,
1463 message_id: &str,
1464 id: &str,
1465 ordinal: usize,
1466 value: &Value,
1467 message_ts: DateTime<Utc>,
1468) -> MappedPart {
1469 let call_id = extract_str(value, "callID");
1470 let name = extract_str(value, "tool");
1471 let state = value.get("state");
1472 let status = state.and_then(|s| s.get("status")).and_then(Value::as_str);
1473 let result_ts = millis_at(value, &["state", "time", "end"]).unwrap_or(message_ts);
1474
1475 let mut owned_state = state.cloned().unwrap_or(Value::Null);
1479 let (input, result) = match owned_state.as_object_mut() {
1480 Some(map) => {
1481 let input = map.remove("input").unwrap_or(Value::Null);
1482 let result = map
1483 .remove("output")
1484 .or_else(|| map.remove("error"))
1485 .unwrap_or_else(|| {
1486 std::mem::take(&mut owned_state)
1488 });
1489 (input, result)
1490 }
1491 None => (Value::Null, Value::Null),
1492 };
1493
1494 let tool_call = Part {
1495 session_id: session_id.to_owned(),
1496 id: id.to_owned(),
1497 message_id: message_id.to_owned(),
1498 ordinal: part_ordinal(ordinal),
1499 provenance: Provenance::Conversational,
1501 options: opencode_raw(value),
1502 kind: PartKind::ToolCall {
1503 call_id: call_id.clone(),
1504 name: name.clone(),
1505 params: input,
1506 provider_executed: false,
1507 },
1508 };
1509
1510 let tool_message_id = format!("{id}/result");
1511 let tool_message = Message::Tool {
1512 id: tool_message_id.clone(),
1513 session_id: session_id.to_owned(),
1514 timestamp: result_ts,
1515 options: synthetic_options(),
1516 };
1517 let result_part = Part {
1518 session_id: session_id.to_owned(),
1519 id: part_id(&tool_message_id, 0),
1520 message_id: tool_message_id,
1521 ordinal: 0,
1522 provenance: Provenance::Injected,
1524 options: synthetic_options(),
1525 kind: PartKind::ToolResult {
1526 call_id,
1527 name,
1528 is_failure: status == Some("error"),
1529 result,
1530 },
1531 };
1532
1533 MappedPart {
1534 part: tool_call,
1535 tool_split: Some(ToolSplit {
1536 message: tool_message,
1537 result: result_part,
1538 }),
1539 }
1540}
1541
1542#[inline]
1543fn opencode_raw(value: &Value) -> ProviderOptions {
1544 source_options(NAME, value)
1545}
1546
1547fn synthetic_options() -> ProviderOptions {
1551 let mut options = ProviderOptions::new();
1552 options.insert("opencode".to_owned(), json!({ "synthetic": true }));
1553 options
1554}
1555
1556fn millis_at(value: &Value, path: &[&str]) -> Option<DateTime<Utc>> {
1557 let mut cursor = value;
1558 for key in path {
1559 cursor = cursor.get(key)?;
1560 }
1561 DateTime::from_timestamp_millis(cursor.as_i64()?)
1562}
1563
1564fn is_synthetic(options: &ProviderOptions) -> bool {
1565 options
1566 .get("opencode")
1567 .and_then(|o| o.get("synthetic"))
1568 .and_then(Value::as_bool)
1569 == Some(true)
1570}
1571
1572fn serialize_native(
1573 session: &crate::sessions::SessionWithMessages,
1574) -> Result<Vec<RestoredFile>, AdapterError> {
1575 let Some(session_raw) = raw_record(&session.session.options) else {
1591 return serialize_foreign(session);
1592 };
1593
1594 let mut ordered = session.messages.clone();
1596 ordered.sort_by(by_timestamp_then_id);
1597
1598 let mut messages = Vec::with_capacity(ordered.len());
1599 for message in &ordered {
1600 if is_synthetic(message.message.options()) {
1601 continue;
1602 }
1603 let Some(info) = raw_record(message.message.options()) else {
1608 return serialize_foreign(session);
1609 };
1610 let mut parts = Vec::with_capacity(message.parts.len());
1611 for part in &message.parts {
1612 if let Some(raw) = raw_record(&part.options) {
1615 parts.push(raw);
1616 }
1617 }
1618 messages.push(json!({ "info": info, "parts": parts }));
1619 }
1620
1621 import_file(
1622 &session.session.id,
1623 session_raw,
1624 messages,
1625 RestoreFidelity::Native,
1626 )
1627}
1628
1629fn serialize_foreign(
1630 session: &crate::sessions::SessionWithMessages,
1631) -> Result<Vec<RestoredFile>, AdapterError> {
1632 let created = session.session.created_at.timestamp_millis();
1640 let info = json!({
1641 "id": session.session.id,
1642 "projectID": encode_project(&session.session.project),
1643 "directory": &*session.session.project,
1644 "time": { "created": created, "updated": created },
1645 });
1646
1647 let mut ordered = session.messages.clone();
1649 ordered.sort_by(by_timestamp_then_id);
1650
1651 let mut messages = Vec::with_capacity(ordered.len());
1652 for message in &ordered {
1653 let role = match message.message {
1654 Message::User { .. } => "user",
1655 Message::Assistant { .. } => "assistant",
1656 Message::Tool { .. } | Message::System { .. } => continue,
1658 };
1659 let created = message.message.timestamp().timestamp_millis();
1660 let msg_info = json!({
1661 "id": message.message.id(),
1662 "sessionID": session.session.id,
1663 "role": role,
1664 "time": { "created": created },
1665 });
1666 let mut parts = Vec::with_capacity(message.parts.len());
1667 for part in &message.parts {
1668 if let Some(record) = foreign_part(&session.session.id, part) {
1669 parts.push(record);
1670 }
1671 }
1672 messages.push(json!({ "info": msg_info, "parts": parts }));
1673 }
1674
1675 import_file(
1676 &session.session.id,
1677 info,
1678 messages,
1679 RestoreFidelity::Foreign,
1680 )
1681}
1682
1683fn import_file(
1686 session_id: &str,
1687 info: Value,
1688 messages: Vec<Value>,
1689 fidelity: RestoreFidelity,
1690) -> Result<Vec<RestoredFile>, AdapterError> {
1691 let body = json!({ "info": info, "messages": messages });
1692 Ok(vec![RestoredFile::new(
1693 PathBuf::from(format!("{session_id}.json")),
1694 encode(&body, session_id)?,
1695 fidelity,
1696 )])
1697}
1698
1699fn foreign_part(session_id: &str, part: &Part) -> Option<Value> {
1700 let mut record = match &part.kind {
1701 PartKind::Text { text } => json!({
1702 "type": "text",
1703 "text": text.as_deref().map(|t| &**t),
1704 "synthetic": part.provenance == Provenance::Injected,
1705 }),
1706 PartKind::Reasoning { text } => json!({
1707 "type": "reasoning",
1708 "text": text.as_deref().map(|t| &**t),
1709 }),
1710 PartKind::File {
1711 media_type,
1712 file_name,
1713 data,
1714 } => json!({
1715 "type": "file",
1716 "mime": media_type,
1717 "filename": file_name,
1718 "url": match data {
1719 FileData::Url(url) => Some(url.clone()),
1720 _ => None,
1721 },
1722 }),
1723 PartKind::ToolCall {
1724 call_id,
1725 name,
1726 params,
1727 ..
1728 } => json!({
1729 "type": "tool",
1730 "callID": call_id.as_deref().map(|c| &**c),
1731 "tool": name.as_deref().map(|n| &**n),
1732 "state": { "status": "completed", "input": params },
1733 }),
1734 _ => return None,
1736 };
1737 if let Value::Object(map) = &mut record {
1738 map.insert("id".to_owned(), json!(part.id));
1739 map.insert("sessionID".to_owned(), json!(session_id));
1740 map.insert("messageID".to_owned(), json!(part.message_id));
1741 }
1742 Some(record)
1743}
1744
1745fn encode(value: &Value, location: &str) -> Result<Vec<u8>, AdapterError> {
1746 serde_json::to_vec(value).map_err(|error| {
1747 AdapterError::schema(
1748 NAME,
1749 location.to_owned(),
1750 format!("json encode failed: {error}"),
1751 )
1752 })
1753}
1754
1755fn encode_project(project: &str) -> String {
1756 project
1757 .chars()
1758 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
1759 .collect()
1760}
1761
1762#[cfg(test)]
1763mod tests {
1764 #![allow(clippy::expect_used, clippy::unwrap_used)]
1769
1770 use super::*;
1771 use crate::{
1772 adapter::{SyncPlan, extract::LEAF_CAP, test_support::MaxWatermarkOracle},
1773 handlers::{SyncEvent, SyncStatus, ingest_adapter},
1774 sessions::Store,
1775 wire::PartKind,
1776 };
1777 use tempfile::TempDir;
1778
1779 const FIXTURES: &str = concat!(
1785 env!("CARGO_MANIFEST_DIR"),
1786 "/tests/fixtures/adapter/opencode/storage"
1787 );
1788 const DATA_DIR: &str = concat!(
1789 env!("CARGO_MANIFEST_DIR"),
1790 "/tests/fixtures/adapter/opencode"
1791 );
1792 const DB_FIXTURE: &str = concat!(
1793 env!("CARGO_MANIFEST_DIR"),
1794 "/tests/fixtures/adapter/opencode/opencode.db"
1795 );
1796 const FRESH_SESSION_ID: &str = "ses_6405e5a5cffeIG2QHRuTmm4mA7";
1797 const FRESH_MESSAGE_ID: &str = "msg_zzzzfresh0001";
1798 const FRESH_PART_ID: &str = "prt_zzzzfresh0001";
1799
1800 const DB_SESSION_COUNT: usize = 10;
1802 const CHILD_SESSION_ID: &str = "ses_09fbc7fc1ffeUaBz77QiNdJbXa";
1803 const CHILD_PARENT_ID: &str = "ses_09fbc87f2ffeyYCZq51oN2HfGa";
1804 const DOCTORED_SESSION_ID: &str = "ses_09fbe676bffe9nYsSBi5xhBlaD";
1805 const DOCTORED_MESSAGE_ID: &str = "msg_f6041991e001BOYvwdP1iI0H0A";
1806 const DOCTORED_DATA_CREATED_MS: i64 = 1_784_026_339_614;
1809 const DOCTORED_COLUMN_CREATED_MS: i64 = 1_794_394_339_614;
1810
1811 fn db_data_dir(temp: &std::path::Path) -> anyhow::Result<PathBuf> {
1814 let dir = temp.join("data");
1815 std::fs::create_dir_all(&dir)?;
1816 std::fs::copy(DB_FIXTURE, dir.join("opencode.db"))?;
1817 Ok(dir)
1818 }
1819
1820 struct FixedOracle {
1821 session_id: &'static str,
1822 watermark_micros: i64,
1823 }
1824
1825 impl crate::adapter::SkipOracle for FixedOracle {
1826 fn session_max_ts(&self, session_id: &str) -> Option<i64> {
1827 (session_id == self.session_id).then_some(self.watermark_micros)
1828 }
1829 }
1830
1831 #[test]
1834 fn probe_default_finds_opencode_data_dir() -> anyhow::Result<()> {
1835 let temp = TempDir::new()?;
1836 let data_dir = temp.path().join(".local").join("share").join("opencode");
1837 std::fs::create_dir_all(data_dir.join("storage"))?;
1838 let env = Env::with_home(temp.path());
1839
1840 let probe = OpencodeFactory.probe_default(&env);
1841 let got = probe
1842 .as_ref()
1843 .and_then(|value| value.get("path"))
1844 .and_then(Value::as_str);
1845 assert_eq!(got, data_dir.to_str(), "probe must return the data dir");
1846
1847 std::fs::remove_dir_all(data_dir.join("storage"))?;
1849 assert!(
1850 OpencodeFactory.probe_default(&env).is_none(),
1851 "an empty data dir must not be offered as a source",
1852 );
1853 Ok(())
1854 }
1855
1856 fn expected_envelope_from_tree(tree_base: &std::path::Path, session_id: &str) -> Value {
1864 let read = |path: &std::path::Path| -> Value {
1865 let raw: Value =
1866 serde_json::from_slice(&std::fs::read(path).expect("read fixture file"))
1867 .expect("fixture file is JSON");
1868 crate::adapter::extract::extract_raw_record(&raw)
1869 };
1870
1871 let mut session_json = None;
1873 for project in std::fs::read_dir(tree_base.join("session")).expect("session dir") {
1874 let candidate = project
1875 .expect("session project entry")
1876 .path()
1877 .join(format!("{session_id}.json"));
1878 if candidate.exists() {
1879 session_json = Some(read(&candidate));
1880 break;
1881 }
1882 }
1883 let session_json = session_json.expect("session file in fixture tree");
1884
1885 let mut message_files =
1886 list_json_sorted(&tree_base.join("message").join(session_id)).expect("message dir");
1887 message_files.sort_by(|a, b| {
1888 let (ma, mb) = (read(a), read(b));
1889 ma["time"]["created"]
1890 .as_i64()
1891 .cmp(&mb["time"]["created"].as_i64())
1892 .then_with(|| ma["id"].as_str().cmp(&mb["id"].as_str()))
1893 });
1894 let mut messages = Vec::new();
1895 for message_path in &message_files {
1896 let msg = read(message_path);
1897 let message_id = msg["id"].as_str().expect("message id").to_owned();
1898 let part_files =
1899 list_json_sorted(&tree_base.join("part").join(&message_id)).expect("part dir");
1900 let parts: Vec<Value> = part_files.iter().map(|p| read(p)).collect();
1901 messages.push(json!({ "info": msg, "parts": parts }));
1902 }
1903 json!({ "info": session_json, "messages": messages })
1904 }
1905
1906 fn serialize_native_body(session: &crate::sessions::SessionWithMessages) -> Value {
1909 let files = serialize_native(session).expect("native serialize");
1910 assert_eq!(files.len(), 1, "one file per session");
1911 let file = &files[0];
1912 assert_eq!(file.actual_fidelity, RestoreFidelity::Native);
1913 assert_eq!(
1914 file.relative_path,
1915 PathBuf::from(format!("{}.json", session.session.id)),
1916 "native emits <session_id>.json at the root",
1917 );
1918 let body: Value = serde_json::from_slice(&file.bytes).expect("emitted body is JSON");
1919 assert!(body.get("info").is_some(), "envelope carries session info");
1920 assert!(
1921 body.get("messages").and_then(Value::as_array).is_some(),
1922 "envelope carries a messages array",
1923 );
1924 body
1925 }
1926
1927 #[tokio::test(flavor = "multi_thread")]
1930 async fn native_restore_emits_import_shape_from_tree() -> anyhow::Result<()> {
1931 let temp = TempDir::new()?;
1932 let source = temp.path().join("storage");
1933 copy_dir(std::path::Path::new(FIXTURES), &source)?;
1934 let store = Store::open_local(temp.path().join("store")).await?;
1935 let adapter = OpencodeAdapter::new(&source);
1936 ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
1937
1938 let session_ids = store.session_ids().await?;
1939 assert!(!session_ids.is_empty(), "tree fixture ingests sessions");
1940 for session_id in session_ids {
1941 let session = store
1942 .get_session(&session_id)
1943 .await?
1944 .expect("session round-trips");
1945 let body = serialize_native_body(&session);
1946 assert_eq!(
1947 body,
1948 expected_envelope_from_tree(&source, &session_id),
1949 "tree session {session_id} embeds its raw_records into the envelope",
1950 );
1951 let real_ids: std::collections::HashSet<&str> = session
1954 .messages
1955 .iter()
1956 .filter(|m| !is_synthetic(m.message.options()))
1957 .map(|m| m.message.id())
1958 .collect();
1959 for message in body["messages"].as_array().unwrap() {
1960 let id = message["info"]["id"].as_str().expect("message info id");
1961 assert!(
1962 real_ids.contains(id),
1963 "emitted message {id} must be a real canonical message",
1964 );
1965 }
1966 }
1967 Ok(())
1968 }
1969
1970 #[tokio::test(flavor = "multi_thread")]
1976 async fn native_restore_conformance_against_db_fixture() -> anyhow::Result<()> {
1977 let temp = TempDir::new()?;
1978 let data = db_data_dir(temp.path())?;
1979 let store = Store::open_local(temp.path().join("store")).await?;
1980 ingest_adapter(
1981 &store,
1982 &OpencodeAdapter::new(&data),
1983 &crate::adapter::NoopOracle,
1984 |_| {},
1985 )
1986 .await?;
1987
1988 let db = std::path::Path::new(DB_FIXTURE);
1990 let conn = open_db(db)?;
1991 let expected_session: HashMap<String, Value> = conn
1992 .prepare(&format!("SELECT {SESSION_COLUMNS} FROM session"))?
1993 .query_map([], session_info_from_row)?
1994 .map(|row| {
1995 let info = row.expect("session row reconstructs");
1996 let id = info["id"].as_str().expect("session id").to_owned();
1997 (id, crate::adapter::extract::extract_raw_record(&info))
1998 })
1999 .collect();
2000 let reconstruct_expected = |data: &str, inject: Vec<(&'static str, Value)>| -> Value {
2001 reconstruct_record(data, inject, std::path::Path::new(""), "", "").unwrap()
2002 };
2003
2004 let mut file_count = 0usize;
2005 let mut saw_child = false;
2006 let mut saw_parent = false;
2007 for session_id in store.session_ids().await? {
2008 let session = store
2009 .get_session(&session_id)
2010 .await?
2011 .expect("session round-trips");
2012 let body = serialize_native_body(&session);
2013 file_count += 1;
2014 if session_id == CHILD_SESSION_ID {
2015 saw_child = true;
2016 }
2017 if session_id == CHILD_PARENT_ID {
2018 saw_parent = true;
2019 }
2020
2021 assert_eq!(
2023 &body["info"],
2024 expected_session
2025 .get(&session_id)
2026 .unwrap_or_else(|| panic!("session {session_id} in DB")),
2027 "session info equals the row-reconstructed SessionInfo",
2028 );
2029
2030 let mut expected_msg_ids: Vec<String> = Vec::new();
2032 let mut stmt =
2033 conn.prepare("SELECT id, data FROM message WHERE session_id = ?1 ORDER BY id")?;
2034 let msg_rows: Vec<(String, String)> = stmt
2035 .query_map([&session_id], |row| Ok((row.get(0)?, row.get(1)?)))?
2036 .collect::<rusqlite::Result<_>>()?;
2037 for (message_id, data) in &msg_rows {
2038 expected_msg_ids.push(message_id.clone());
2039 let expected_info = reconstruct_expected(
2040 data,
2041 vec![("id", json!(message_id)), ("sessionID", json!(session_id))],
2042 );
2043 let entry = body["messages"]
2044 .as_array()
2045 .unwrap()
2046 .iter()
2047 .find(|m| m["info"]["id"].as_str() == Some(message_id.as_str()))
2048 .unwrap_or_else(|| panic!("message {message_id} emitted"));
2049 assert_eq!(&entry["info"], &expected_info, "message info matches row");
2050
2051 let mut pstmt =
2052 conn.prepare("SELECT id, data FROM part WHERE message_id = ?1 ORDER BY id")?;
2053 let expected_parts: Vec<Value> = pstmt
2054 .query_map([message_id], |row| {
2055 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2056 })?
2057 .map(|r| {
2058 let (part_id, data) = r.unwrap();
2059 reconstruct_expected(
2060 &data,
2061 vec![
2062 ("id", json!(part_id)),
2063 ("sessionID", json!(session_id)),
2064 ("messageID", json!(message_id)),
2065 ],
2066 )
2067 })
2068 .collect();
2069 assert_eq!(
2070 entry["parts"].as_array().unwrap(),
2071 &expected_parts,
2072 "parts for message {message_id} match the rows in id order",
2073 );
2074 }
2075
2076 let emitted_ids: Vec<&str> = body["messages"]
2079 .as_array()
2080 .unwrap()
2081 .iter()
2082 .map(|m| m["info"]["id"].as_str().unwrap())
2083 .collect();
2084 assert_eq!(
2085 emitted_ids.len(),
2086 expected_msg_ids.len(),
2087 "no synthetic message carriers leak into the envelope",
2088 );
2089 }
2090
2091 assert_eq!(file_count, DB_SESSION_COUNT, "one file per DB session");
2092 assert!(
2093 saw_child,
2094 "child session {CHILD_SESSION_ID} gets its own file"
2095 );
2096 assert!(
2097 saw_parent,
2098 "parent session {CHILD_PARENT_ID} gets its own file",
2099 );
2100 Ok(())
2101 }
2102
2103 async fn plan_gate_agreement(
2109 adapter: &OpencodeAdapter,
2110 ) -> anyhow::Result<(SyncPlan, SyncPlan)> {
2111 use tokio_stream::StreamExt;
2112
2113 let first_sync = adapter
2114 .plan(&crate::adapter::NoopOracle)
2115 .await?
2116 .expect("opencode supports plan");
2117 assert_eq!(
2118 first_sync.pending, first_sync.sessions,
2119 "a first sync reads every session"
2120 );
2121 assert_eq!(first_sync.fresh, 0);
2122
2123 let max_plan = adapter
2124 .plan(&MaxWatermarkOracle)
2125 .await?
2126 .expect("opencode supports plan");
2127 assert_eq!(
2128 max_plan.sessions, first_sync.sessions,
2129 "session count is stable across oracles",
2130 );
2131
2132 let mut gate_fresh = 0usize;
2133 let mut stream = adapter.events_with(&MaxWatermarkOracle);
2134 while let Some(item) = stream.next().await {
2135 match item? {
2136 AdapterYield::Skipped {
2137 reason: SkipReason::Fresh,
2138 ..
2139 } => gate_fresh += 1,
2140 AdapterYield::SkippedBatch {
2141 reason: SkipReason::Fresh,
2142 count,
2143 } => gate_fresh += count,
2144 _ => {}
2145 }
2146 }
2147 assert_eq!(gate_fresh, max_plan.fresh, "plan and gate must agree");
2148 Ok((first_sync, max_plan))
2149 }
2150
2151 #[tokio::test(flavor = "multi_thread")]
2157 async fn plan_matches_the_events_gate() -> anyhow::Result<()> {
2158 let temp = TempDir::new()?;
2159 let source = temp.path().join("storage");
2160 copy_dir(std::path::Path::new(FIXTURES), &source)?;
2161 let empty_dir = source.join("session").join("proj-empty");
2162 std::fs::create_dir_all(&empty_dir)?;
2163 std::fs::write(
2164 empty_dir.join("ses_zzzzemptysession00000000.json"),
2165 "{\"id\":\"ses_zzzzemptysession00000000\",\"projectID\":\"proj-empty\",\
2166 \"directory\":\"/tmp/pond-test\",\"time\":{\"created\":1759859990000}}",
2167 )?;
2168
2169 let adapter = OpencodeAdapter::new(&source);
2170 let (first_sync, max_plan) = plan_gate_agreement(&adapter).await?;
2171 assert!(first_sync.sessions > 1);
2172 assert_eq!(
2173 max_plan.pending, 1,
2174 "the message-less session must stay pending - its Session row only \
2175 lands by reading it",
2176 );
2177 Ok(())
2178 }
2179
2180 const FRESH_TURN_MICROS: i64 = 1_759_859_999_000 * 1_000;
2183
2184 #[tokio::test(flavor = "multi_thread")]
2187 async fn freshness_re_reads_a_session_that_gained_a_newer_message() -> anyhow::Result<()> {
2188 let temp = TempDir::new()?;
2189 let source = temp.path().join("storage");
2190 copy_dir(std::path::Path::new(FIXTURES), &source)?;
2191
2192 let store = Store::open_local(temp.path().join("store")).await?;
2193 let adapter = OpencodeAdapter::new(&source);
2194 ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
2195
2196 append_fresh_opencode_turn(&source)?;
2197 let oracle = FixedOracle {
2199 session_id: FRESH_SESSION_ID,
2200 watermark_micros: FRESH_TURN_MICROS - 1,
2201 };
2202 ingest_adapter(&store, &adapter, &oracle, |_| {}).await?;
2203
2204 let session = store
2205 .get_session(FRESH_SESSION_ID)
2206 .await?
2207 .expect("fixture session round-trips");
2208 let fresh = session
2209 .messages
2210 .iter()
2211 .find(|stored| stored.message.id() == FRESH_MESSAGE_ID)
2212 .expect("message newer than the watermark must land");
2213 assert!(
2214 fresh.parts.iter().any(|part| matches!(
2215 &part.kind,
2216 PartKind::Text { text } if text.as_deref().map(|value| value.as_str()) == Some("fresh opencode text")
2217 )),
2218 "fresh message part must land with the re-read session",
2219 );
2220 Ok(())
2221 }
2222
2223 #[tokio::test(flavor = "multi_thread")]
2226 async fn freshness_skips_a_session_not_newer_than_the_watermark() -> anyhow::Result<()> {
2227 let temp = TempDir::new()?;
2228 let source = temp.path().join("storage");
2229 copy_dir(std::path::Path::new(FIXTURES), &source)?;
2230
2231 let store = Store::open_local(temp.path().join("store")).await?;
2232 let adapter = OpencodeAdapter::new(&source);
2233 ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
2234
2235 append_fresh_opencode_turn(&source)?;
2236 let oracle = FixedOracle {
2238 session_id: FRESH_SESSION_ID,
2239 watermark_micros: FRESH_TURN_MICROS,
2240 };
2241 let summary = ingest_adapter(&store, &adapter, &oracle, |_| {}).await?;
2242
2243 assert!(
2244 summary.skipped_fresh >= 1,
2245 "the unchanged-vs-watermark session must be skipped, got {summary:?}",
2246 );
2247 let session = store
2248 .get_session(FRESH_SESSION_ID)
2249 .await?
2250 .expect("fixture session round-trips");
2251 assert!(
2252 !session
2253 .messages
2254 .iter()
2255 .any(|stored| stored.message.id() == FRESH_MESSAGE_ID),
2256 "a skipped session must not re-read the appended turn",
2257 );
2258 Ok(())
2259 }
2260
2261 #[tokio::test(flavor = "multi_thread")]
2262 async fn malformed_part_file_drops_only_that_part() -> anyhow::Result<()> {
2263 let temp = TempDir::new()?;
2264 let source = temp.path().join("storage");
2265 write_minimal_session(&source, "ses_badpart", "msg_badpart")?;
2266 let part_dir = source.join("part").join("msg_badpart");
2267 std::fs::write(part_dir.join("prt_000_bad.json"), b"{not json")?;
2268 write_json_file(
2269 &part_dir.join("prt_999_good.json"),
2270 &json!({
2271 "id": "prt_999_good",
2272 "sessionID": "ses_badpart",
2273 "messageID": "msg_badpart",
2274 "type": "text",
2275 "text": "valid sibling survives",
2276 "synthetic": false,
2277 }),
2278 )?;
2279
2280 let store = Store::open_local(temp.path().join("store")).await?;
2281 let summary = ingest_adapter(
2282 &store,
2283 &OpencodeAdapter::new(&source),
2284 &crate::adapter::NoopOracle,
2285 |_| {},
2286 )
2287 .await?;
2288
2289 assert_eq!(summary.dropped_events, 1);
2290 let session = store
2291 .get_session("ses_badpart")
2292 .await?
2293 .expect("session with one malformed part still lands");
2294 let message = session
2295 .messages
2296 .iter()
2297 .find(|stored| stored.message.id() == "msg_badpart")
2298 .expect("message with valid sibling part still lands");
2299 assert!(message.parts.iter().any(|part| {
2300 matches!(
2301 &part.kind,
2302 PartKind::Text { text }
2303 if text.as_deref().map(String::as_str) == Some("valid sibling survives")
2304 )
2305 }));
2306 Ok(())
2307 }
2308
2309 #[test]
2310 fn missing_message_timestamp_uses_session_anchor() -> anyhow::Result<()> {
2311 let session_anchor =
2312 DateTime::parse_from_rfc3339("2026-05-05T12:13:14Z")?.with_timezone(&Utc);
2313 let events = build_message_events(
2314 "ses_anchor",
2315 &json!({"id": "msg_no_time", "role": "user"}),
2316 &[],
2317 session_anchor,
2318 )?;
2319
2320 let IngestEvent::Message(message) = &events[0] else {
2321 panic!("first event is the message");
2322 };
2323 assert_eq!(message.timestamp(), session_anchor);
2324 Ok(())
2325 }
2326
2327 #[test]
2328 fn source_part_without_id_is_schema_error() {
2329 let session_anchor = DateTime::from_timestamp_millis(1_765_000_000_000).unwrap();
2330 let error = build_message_events(
2331 "ses_missing_part_id",
2332 &json!({
2333 "id": "msg_missing_part_id",
2334 "role": "assistant",
2335 "time": { "created": 1_765_000_000_000i64 },
2336 }),
2337 &[json!({"type": "text", "text": "cannot restore its filename"})],
2338 session_anchor,
2339 )
2340 .expect_err("part ids are required for native filename replay");
2341
2342 assert!(error.to_string().contains("part missing `id`"));
2343 }
2344
2345 #[test]
2346 fn read_json_bounds_oversized_string_leaves() -> anyhow::Result<()> {
2347 let temp = TempDir::new()?;
2348 let path = temp.path().join("oversized.json");
2349 write_json_file(
2350 &path,
2351 &json!({
2352 "id": "oversized",
2353 "text": "x".repeat(LEAF_CAP + 100),
2354 }),
2355 )?;
2356
2357 let value = read_json(&path)?;
2358 let text = value
2359 .get("text")
2360 .and_then(Value::as_str)
2361 .expect("text leaf survives as a bounded marker");
2362 assert!(text.len() <= LEAF_CAP);
2363 assert!(text.ends_with(&format!("{} bytes>", LEAF_CAP + 100)));
2364 Ok(())
2365 }
2366
2367 #[tokio::test(flavor = "multi_thread")]
2368 async fn opencode_adapter_ingests_fixture_corpus_into_canonical_shape() -> anyhow::Result<()> {
2369 let temp = TempDir::new()?;
2371 let store = Store::open_local(temp.path()).await?;
2372 let adapter = OpencodeAdapter::new(DATA_DIR);
2373
2374 let summary = ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
2375 assert!(summary.accepted() > 0, "ingest must accept rows");
2376 assert_eq!(summary.dropped_events, 0, "no per-event drops expected");
2377 assert_eq!(
2378 summary.dropped_sessions, 0,
2379 "no session-level rejections expected"
2380 );
2381
2382 let (sessions, messages, parts) = store.row_counts().await?;
2383 assert!(
2384 sessions >= DB_SESSION_COUNT,
2385 "the DB sessions plus the tree beside them all ingest",
2386 );
2387 assert!(messages > 0, "at least one opencode message");
2388 assert!(parts > 0, "at least one opencode Part");
2389
2390 let mut saw_call = false;
2391 let mut saw_result = false;
2392 let mut saw_injected_text = false;
2393 for session_id in store.session_ids().await? {
2394 let session = store
2395 .get_session(&session_id)
2396 .await?
2397 .expect("session round-trips");
2398 assert!(
2399 session.session.source_agent.starts_with(NAME),
2400 "source_agent is `opencode` or `opencode/<agent>`, got {}",
2401 session.session.source_agent,
2402 );
2403 assert!(
2404 !(*session.session.project).is_empty(),
2405 "spec.md#model-project-non-empty",
2406 );
2407 for stored in &session.messages {
2408 for part in &stored.parts {
2409 match &part.kind {
2410 PartKind::ToolCall { .. } => saw_call = true,
2411 PartKind::ToolResult { .. } => saw_result = true,
2412 PartKind::Text { .. } if part.provenance == Provenance::Injected => {
2413 saw_injected_text = true;
2414 }
2415 _ => {}
2416 }
2417 }
2418 }
2419 }
2420 assert!(saw_call, "fused tool parts yield ToolCall on the assistant");
2421 assert!(
2422 saw_result,
2423 "fused tool parts split off a ToolResult on a Tool message",
2424 );
2425 assert!(
2426 saw_injected_text,
2427 "spec.md#model-part-provenance: synthetic text parts are injected",
2428 );
2429 Ok(())
2430 }
2431
2432 #[tokio::test(flavor = "multi_thread")]
2436 async fn fused_tool_part_splits_into_call_and_result() -> anyhow::Result<()> {
2437 let temp = TempDir::new()?;
2438 let store = Store::open_local(temp.path()).await?;
2439 let adapter = OpencodeAdapter::new(DATA_DIR);
2440 ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
2441
2442 let mut call_ids = std::collections::HashSet::new();
2443 let mut result_ids = std::collections::HashSet::new();
2444 let mut saw_failure = false;
2445 for session_id in store.session_ids().await? {
2446 let session = store
2447 .get_session(&session_id)
2448 .await?
2449 .expect("session round-trips");
2450 for stored in &session.messages {
2451 for part in &stored.parts {
2452 match &part.kind {
2453 PartKind::ToolCall { call_id, .. } => {
2454 if let Some(id) = call_id.as_deref() {
2455 call_ids.insert(id.clone());
2456 }
2457 }
2458 PartKind::ToolResult {
2459 call_id,
2460 is_failure,
2461 result,
2462 ..
2463 } => {
2464 assert!(
2465 matches!(stored.message, Message::Tool { .. }),
2466 "a ToolResult must live on a Tool-role message",
2467 );
2468 if *is_failure {
2469 saw_failure = true;
2470 assert_ne!(
2471 result,
2472 &Value::Null,
2473 "failed tool results must carry the source error/output payload",
2474 );
2475 }
2476 if let Some(id) = call_id.as_deref() {
2477 result_ids.insert(id.clone());
2478 }
2479 }
2480 _ => {}
2481 }
2482 }
2483 }
2484 }
2485 assert!(!call_ids.is_empty(), "corpus has tool calls");
2486 assert_eq!(
2487 call_ids, result_ids,
2488 "every tool call's id is matched by its split-off result",
2489 );
2490 assert!(
2491 saw_failure,
2492 "fixture has at least one failed opencode tool result"
2493 );
2494 Ok(())
2495 }
2496
2497 #[tokio::test(flavor = "multi_thread")]
2498 async fn db_fixture_ingests_expected_census() -> anyhow::Result<()> {
2499 let temp = TempDir::new()?;
2500 let data = db_data_dir(temp.path())?;
2501 let store = Store::open_local(temp.path().join("store")).await?;
2502 let summary = ingest_adapter(
2503 &store,
2504 &OpencodeAdapter::new(&data),
2505 &crate::adapter::NoopOracle,
2506 |_| {},
2507 )
2508 .await?;
2509 assert_eq!(summary.dropped_events, 0, "no per-event drops expected");
2510 assert_eq!(summary.dropped_sessions, 0, "no session-level rejections");
2511
2512 let (sessions, messages, parts) = store.row_counts().await?;
2513 assert_eq!(sessions, DB_SESSION_COUNT, "every DB session ingests");
2514 assert!(
2515 messages >= 27,
2516 "27 message rows plus synthetic tool carriers"
2517 );
2518 assert!(parts >= 69, "69 part rows plus split-off tool results");
2519
2520 assert!(
2523 store.get_session(CHILD_SESSION_ID).await?.is_some(),
2524 "child session ingests",
2525 );
2526 assert!(
2527 store.get_session(DOCTORED_SESSION_ID).await?.is_some(),
2528 "migration-stamped session ingests",
2529 );
2530 Ok(())
2531 }
2532
2533 #[tokio::test(flavor = "multi_thread")]
2534 async fn child_session_labeled_as_subagent() -> anyhow::Result<()> {
2535 let temp = TempDir::new()?;
2536 let data = db_data_dir(temp.path())?;
2537 let store = Store::open_local(temp.path().join("store")).await?;
2538 ingest_adapter(
2539 &store,
2540 &OpencodeAdapter::new(&data),
2541 &crate::adapter::NoopOracle,
2542 |_| {},
2543 )
2544 .await?;
2545
2546 let child = store
2547 .get_session(CHILD_SESSION_ID)
2548 .await?
2549 .expect("child session ingests");
2550 assert_eq!(
2551 child.session.parent_session_id.as_deref(),
2552 Some(CHILD_PARENT_ID),
2553 "child carries its parent pointer",
2554 );
2555 assert_eq!(
2556 child.session.source_agent, "opencode/general",
2557 "spec.md: a subagent session is labeled opencode/<agent>",
2558 );
2559 Ok(())
2560 }
2561
2562 #[tokio::test(flavor = "multi_thread")]
2565 async fn doctored_row_uses_data_time_not_column() -> anyhow::Result<()> {
2566 let temp = TempDir::new()?;
2567 let data = db_data_dir(temp.path())?;
2568 let store = Store::open_local(temp.path().join("store")).await?;
2569 ingest_adapter(
2570 &store,
2571 &OpencodeAdapter::new(&data),
2572 &crate::adapter::NoopOracle,
2573 |_| {},
2574 )
2575 .await?;
2576
2577 let session = store
2578 .get_session(DOCTORED_SESSION_ID)
2579 .await?
2580 .expect("doctored session ingests");
2581 let message = session
2582 .messages
2583 .iter()
2584 .find(|stored| stored.message.id() == DOCTORED_MESSAGE_ID)
2585 .expect("doctored message ingests");
2586 assert_eq!(
2587 message.message.timestamp().timestamp_millis(),
2588 DOCTORED_DATA_CREATED_MS,
2589 "canonical timestamp is data.time.created, not the migration column",
2590 );
2591
2592 let conn = open_db(std::path::Path::new(DB_FIXTURE))?;
2595 let watermark =
2596 db_session_watermark(&conn, std::path::Path::new(DB_FIXTURE), DOCTORED_SESSION_ID)?
2597 .expect("session has a message");
2598 assert!(
2599 watermark < DOCTORED_COLUMN_CREATED_MS * 1_000,
2600 "watermark ({watermark} micros) must ignore the future time_created column",
2601 );
2602 Ok(())
2603 }
2604
2605 #[tokio::test(flavor = "multi_thread")]
2608 async fn injected_carrier_part_types_land() -> anyhow::Result<()> {
2609 let temp = TempDir::new()?;
2610 let data = db_data_dir(temp.path())?;
2611 {
2612 let conn = Connection::open(data.join("opencode.db"))?;
2613 conn.execute_batch("PRAGMA foreign_keys = OFF;")?;
2617 insert_session(
2618 &conn,
2619 "ses_carrier00000000000000000",
2620 "/tmp/carrier",
2621 None,
2622 None,
2623 1_784_100_000_000,
2624 )?;
2625 insert_message(
2626 &conn,
2627 "msg_carrier00000000000000000",
2628 "ses_carrier00000000000000000",
2629 1_784_100_000_100,
2630 &json!({ "role": "assistant", "time": { "created": 1_784_100_000_100i64 } }),
2631 )?;
2632 for (part_id, body) in [
2633 (
2634 "prt_carrier_a_subtask000000",
2635 json!({ "type": "subtask", "prompt": "p", "description": "d", "agent": "general" }),
2636 ),
2637 (
2638 "prt_carrier_b_compaction00",
2639 json!({ "type": "compaction", "auto": true }),
2640 ),
2641 (
2642 "prt_carrier_c_agent0000000",
2643 json!({ "type": "agent", "name": "build" }),
2644 ),
2645 (
2646 "prt_carrier_d_snapshot0000",
2647 json!({ "type": "snapshot", "snapshot": "abcdef" }),
2648 ),
2649 ] {
2650 insert_part(
2651 &conn,
2652 part_id,
2653 "msg_carrier00000000000000000",
2654 "ses_carrier00000000000000000",
2655 1_784_100_000_100,
2656 &body,
2657 )?;
2658 }
2659 }
2660
2661 let store = Store::open_local(temp.path().join("store")).await?;
2662 ingest_adapter(
2663 &store,
2664 &OpencodeAdapter::new(&data),
2665 &crate::adapter::NoopOracle,
2666 |_| {},
2667 )
2668 .await?;
2669
2670 let session = store
2671 .get_session("ses_carrier00000000000000000")
2672 .await?
2673 .expect("carrier session ingests");
2674 let mut seen_types = std::collections::HashSet::new();
2675 for stored in &session.messages {
2676 for part in &stored.parts {
2677 let Some(raw) = raw_record(&part.options) else {
2678 continue;
2679 };
2680 if let Some(kind) = raw.get("type").and_then(Value::as_str) {
2681 seen_types.insert(kind.to_owned());
2682 assert_eq!(
2683 part.provenance,
2684 Provenance::Injected,
2685 "carrier part {kind} is injected turn machinery",
2686 );
2687 }
2688 }
2689 }
2690 for expected in ["subtask", "compaction", "agent", "snapshot"] {
2691 assert!(
2692 seen_types.contains(expected),
2693 "carrier type {expected} must land with its raw_record, saw {seen_types:?}",
2694 );
2695 }
2696 Ok(())
2697 }
2698
2699 #[tokio::test(flavor = "multi_thread")]
2702 async fn dual_source_dedup_prefers_db() -> anyhow::Result<()> {
2703 let temp = TempDir::new()?;
2704 let data = db_data_dir(temp.path())?;
2705 let overlap = "ses_09fb956e1ffeKLKCXceMNLRsS0";
2707 let tree_only = "ses_treeonly0000000000000000";
2708 write_tree_session(&data.join("storage"), overlap, "TREE COPY SHOULD LOSE")?;
2709 write_tree_session(&data.join("storage"), tree_only, "tree-only survives")?;
2710
2711 let store = Store::open_local(temp.path().join("store")).await?;
2712 let summary = ingest_adapter(
2713 &store,
2714 &OpencodeAdapter::new(&data),
2715 &crate::adapter::NoopOracle,
2716 |_| {},
2717 )
2718 .await?;
2719 assert!(
2720 summary.skipped_superseded >= 1,
2721 "the deduped tree copy must be counted as superseded, got {summary:?}",
2722 );
2723 assert_eq!(
2724 summary.skipped_empty, 0,
2725 "the dedup must not fold into skipped_empty, got {summary:?}",
2726 );
2727
2728 let overlapped = store
2729 .get_session(overlap)
2730 .await?
2731 .expect("overlapping session ingests (DB copy)");
2732 assert!(
2733 !overlapped.messages.iter().any(|stored| stored.parts.iter().any(|part| matches!(
2734 &part.kind,
2735 PartKind::Text { text } if text.as_deref().map(String::as_str) == Some("TREE COPY SHOULD LOSE")
2736 ))),
2737 "the DB copy wins; the tree body must not leak in",
2738 );
2739 assert!(
2740 store.get_session(tree_only).await?.is_some(),
2741 "the tree-only session still ingests",
2742 );
2743 Ok(())
2744 }
2745
2746 #[tokio::test(flavor = "multi_thread")]
2747 async fn hostile_db_session_id_is_rejected_at_ingest() -> anyhow::Result<()> {
2748 let temp = TempDir::new()?;
2749 let data = db_data_dir(temp.path())?;
2750 let conn = Connection::open(data.join("opencode.db"))?;
2751 conn.pragma_update(None, "foreign_keys", "OFF")?;
2752 conn.execute(
2753 "UPDATE session SET id = '../evil' WHERE id = ?1",
2754 [CHILD_SESSION_ID],
2755 )?;
2756 drop(conn);
2757
2758 let store = Store::open_local(temp.path().join("store")).await?;
2759 let skip_reasons = std::sync::Mutex::new(Vec::new());
2760 let summary = ingest_adapter(
2761 &store,
2762 &OpencodeAdapter::new(&data),
2763 &crate::adapter::NoopOracle,
2764 |event| {
2765 if let SyncEvent::SessionDone(outcome) = &event
2766 && let SyncStatus::Skipped { reason } = &outcome.status
2767 {
2768 skip_reasons.lock().unwrap().push(reason.clone());
2769 }
2770 },
2771 )
2772 .await?;
2773
2774 let skip_reasons = skip_reasons.into_inner().unwrap();
2775 assert!(
2776 skip_reasons
2777 .iter()
2778 .any(|reason| reason.contains("traversal")),
2779 "the hostile id must surface as a typed skip, got {skip_reasons:?}",
2780 );
2781 assert_eq!(summary.skipped_files, 1, "got {summary:?}");
2782 assert!(store.get_session("../evil").await?.is_none());
2783 let (sessions, ..) = store.row_counts().await?;
2784 assert_eq!(
2785 sessions,
2786 DB_SESSION_COUNT - 1,
2787 "every well-formed session still ingests",
2788 );
2789 Ok(())
2790 }
2791
2792 #[test]
2793 fn oversized_json_file_is_rejected_by_the_record_cap() -> anyhow::Result<()> {
2794 let temp = TempDir::new()?;
2795 let path = temp.path().join("big.json");
2796 std::fs::write(&path, vec![b' '; RECORD_CAP + 1])?;
2797 let error = read_json(&path).unwrap_err();
2798 assert!(
2799 error.to_string().contains("record cap"),
2800 "the pre-read size gate must reject the file, got: {error}",
2801 );
2802 Ok(())
2803 }
2804
2805 #[tokio::test(flavor = "multi_thread")]
2806 async fn config_path_ending_in_storage_reads_parent_db() -> anyhow::Result<()> {
2807 let temp = TempDir::new()?;
2808 std::fs::copy(DB_FIXTURE, temp.path().join("opencode.db"))?;
2809 let store = Store::open_local(temp.path().join("store")).await?;
2810 let adapter = OpencodeAdapter::new(temp.path().join("storage"));
2813 ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
2814
2815 let (sessions, ..) = store.row_counts().await?;
2816 assert_eq!(
2817 sessions, DB_SESSION_COUNT,
2818 "the normalized parent's DB is read",
2819 );
2820 Ok(())
2821 }
2822
2823 #[tokio::test(flavor = "multi_thread")]
2827 async fn plan_matches_the_events_gate_db_source() -> anyhow::Result<()> {
2828 let temp = TempDir::new()?;
2829 let data = db_data_dir(temp.path())?;
2830 let adapter = OpencodeAdapter::new(&data);
2831
2832 let (first, max_plan) = plan_gate_agreement(&adapter).await?;
2833 assert_eq!(first.sessions, DB_SESSION_COUNT);
2834 assert_eq!(
2835 max_plan.fresh, DB_SESSION_COUNT,
2836 "every DB session is fresh under a max watermark",
2837 );
2838 Ok(())
2839 }
2840
2841 const APPEND_SESSION_ID: &str = "ses_09fb956e1ffeKLKCXceMNLRsS0";
2842 const APPEND_MESSAGE_ID: &str = "msg_zzzzfreshdb0000000000000";
2843 const APPEND_CREATED_MS: i64 = 1_790_000_000_000;
2844 const APPEND_MICROS: i64 = APPEND_CREATED_MS * 1_000;
2845
2846 fn append_db_turn(data: &std::path::Path) -> anyhow::Result<()> {
2847 let conn = Connection::open(data.join("opencode.db"))?;
2848 insert_message(
2849 &conn,
2850 APPEND_MESSAGE_ID,
2851 APPEND_SESSION_ID,
2852 APPEND_CREATED_MS,
2853 &json!({ "role": "user", "time": { "created": APPEND_CREATED_MS } }),
2854 )?;
2855 insert_part(
2856 &conn,
2857 "prt_zzzzfreshdb0000000000000",
2858 APPEND_MESSAGE_ID,
2859 APPEND_SESSION_ID,
2860 APPEND_CREATED_MS,
2861 &json!({ "type": "text", "text": "fresh db text", "synthetic": false }),
2862 )?;
2863 Ok(())
2864 }
2865
2866 #[tokio::test(flavor = "multi_thread")]
2867 async fn freshness_db_re_reads_a_session_with_a_newer_message() -> anyhow::Result<()> {
2868 let temp = TempDir::new()?;
2869 let data = db_data_dir(temp.path())?;
2870 let store = Store::open_local(temp.path().join("store")).await?;
2871 let adapter = OpencodeAdapter::new(&data);
2872 ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
2873
2874 append_db_turn(&data)?;
2875 let oracle = FixedOracle {
2876 session_id: APPEND_SESSION_ID,
2877 watermark_micros: APPEND_MICROS - 1,
2878 };
2879 ingest_adapter(&store, &adapter, &oracle, |_| {}).await?;
2880
2881 let session = store
2882 .get_session(APPEND_SESSION_ID)
2883 .await?
2884 .expect("session round-trips");
2885 assert!(
2886 session
2887 .messages
2888 .iter()
2889 .any(|stored| stored.message.id() == APPEND_MESSAGE_ID),
2890 "a message newer than the watermark must land",
2891 );
2892 Ok(())
2893 }
2894
2895 #[tokio::test(flavor = "multi_thread")]
2896 async fn freshness_db_skips_a_session_not_newer_than_the_watermark() -> anyhow::Result<()> {
2897 let temp = TempDir::new()?;
2898 let data = db_data_dir(temp.path())?;
2899 let store = Store::open_local(temp.path().join("store")).await?;
2900 let adapter = OpencodeAdapter::new(&data);
2901 ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
2902
2903 append_db_turn(&data)?;
2904 let oracle = FixedOracle {
2905 session_id: APPEND_SESSION_ID,
2906 watermark_micros: APPEND_MICROS,
2907 };
2908 let summary = ingest_adapter(&store, &adapter, &oracle, |_| {}).await?;
2909 assert!(
2910 summary.skipped_fresh >= 1,
2911 "the unchanged-vs-watermark session must be skipped, got {summary:?}",
2912 );
2913
2914 let session = store
2915 .get_session(APPEND_SESSION_ID)
2916 .await?
2917 .expect("session round-trips");
2918 assert!(
2919 !session
2920 .messages
2921 .iter()
2922 .any(|stored| stored.message.id() == APPEND_MESSAGE_ID),
2923 "a skipped session must not re-read the appended turn",
2924 );
2925 Ok(())
2926 }
2927
2928 #[tokio::test(flavor = "multi_thread")]
2929 async fn malformed_db_data_drops_only_that_record() -> anyhow::Result<()> {
2930 let temp = TempDir::new()?;
2931 let data = db_data_dir(temp.path())?;
2932 {
2933 let conn = Connection::open(data.join("opencode.db"))?;
2934 conn.execute(
2935 "UPDATE part SET data = '{not json' WHERE id = (SELECT id FROM part ORDER BY id LIMIT 1)",
2936 [],
2937 )?;
2938 }
2939
2940 let store = Store::open_local(temp.path().join("store")).await?;
2941 let summary = ingest_adapter(
2942 &store,
2943 &OpencodeAdapter::new(&data),
2944 &crate::adapter::NoopOracle,
2945 |_| {},
2946 )
2947 .await?;
2948 assert_eq!(
2949 summary.dropped_events, 1,
2950 "only the malformed part is dropped, got {summary:?}",
2951 );
2952 let (sessions, ..) = store.row_counts().await?;
2953 assert_eq!(sessions, DB_SESSION_COUNT, "all sessions still ingest");
2954 Ok(())
2955 }
2956
2957 #[tokio::test(flavor = "multi_thread")]
2958 async fn legacy_tree_only_root_still_ingests() -> anyhow::Result<()> {
2959 let temp = TempDir::new()?;
2960 let source = temp.path().join("storage");
2961 copy_dir(std::path::Path::new(FIXTURES), &source)?;
2962 let store = Store::open_local(temp.path().join("store")).await?;
2963 ingest_adapter(
2964 &store,
2965 &OpencodeAdapter::new(&source),
2966 &crate::adapter::NoopOracle,
2967 |_| {},
2968 )
2969 .await?;
2970
2971 let (sessions, ..) = store.row_counts().await?;
2972 assert!(sessions > 0, "the bare legacy tree still ingests");
2973 assert!(
2974 store.get_session(FRESH_SESSION_ID).await?.is_some(),
2975 "a known tree session ingests with no DB present",
2976 );
2977 Ok(())
2978 }
2979
2980 #[tokio::test(flavor = "multi_thread")]
2986 async fn foreign_serialization_emits_import_shape() -> anyhow::Result<()> {
2987 let temp = TempDir::new()?;
2988 let origin_store = Store::open_local(temp.path().join("origin-store")).await?;
2989 let origin = crate::adapter::PiCodingAgentAdapter::new(concat!(
2990 env!("CARGO_MANIFEST_DIR"),
2991 "/tests/fixtures/adapter/pi-coding-agent/sessions"
2992 ));
2993 ingest_adapter(&origin_store, &origin, &crate::adapter::NoopOracle, |_| {}).await?;
2994
2995 let mut saw_tool = false;
2996 for session_id in origin_store.session_ids().await? {
2997 let session = origin_store
2998 .get_session(&session_id)
2999 .await?
3000 .expect("fixture session is readable");
3001
3002 let mut files = OpencodeFactory.serialize(&session, RestoreFidelity::Foreign)?;
3003 assert_eq!(files.len(), 1, "foreign emits one file per session");
3004 let file = files.remove(0);
3005 assert_eq!(file.actual_fidelity, RestoreFidelity::Foreign);
3006 assert_eq!(
3007 file.relative_path,
3008 PathBuf::from(format!("{session_id}.json")),
3009 "foreign emits <session_id>.json at the root",
3010 );
3011 let body: Value = serde_json::from_slice(&file.bytes)?;
3012 crate::adapter::write_restored_files(
3014 &temp.path().join("opencode-restore"),
3015 vec![file],
3016 )?;
3017 assert_eq!(
3018 body["info"]["id"].as_str(),
3019 Some(session_id.as_str()),
3020 "envelope info carries the session id",
3021 );
3022 assert!(
3023 body["info"]["directory"].is_string(),
3024 "foreign session info carries a directory",
3025 );
3026 let messages = body["messages"]
3027 .as_array()
3028 .expect("envelope carries a messages array");
3029
3030 for message in messages {
3031 let role = message["info"]["role"].as_str().expect("message info role");
3032 assert!(
3033 role == "user" || role == "assistant",
3034 "only user/assistant carriers survive foreign restore, got {role}",
3035 );
3036 assert_eq!(
3037 message["info"]["sessionID"].as_str(),
3038 Some(session_id.as_str()),
3039 );
3040 for part in message["parts"].as_array().expect("parts array") {
3041 if part["type"].as_str() == Some("tool") {
3042 saw_tool = true;
3043 assert_eq!(
3044 part["state"]["status"].as_str(),
3045 Some("completed"),
3046 "a re-fused tool part is completed",
3047 );
3048 assert!(
3049 part["state"].get("input").is_some(),
3050 "a re-fused tool part carries its input",
3051 );
3052 }
3053 }
3054 }
3055 }
3056 assert!(
3057 saw_tool,
3058 "the pi fixture's tool call re-fuses into a `tool` part",
3059 );
3060 Ok(())
3061 }
3062
3063 #[test]
3064 fn path_ids_reject_separators_and_traversal() {
3065 let where_ = "session/project/session.json";
3066 assert!(validate_path_id(NAME, "session id", "ses_safe", where_).is_ok());
3067 assert!(validate_path_id(NAME, "session id", "../ses", where_).is_err());
3068 assert!(validate_path_id(NAME, "session id", "/tmp/ses", where_).is_err());
3069 assert!(validate_path_id(NAME, "message id", "msg/a", where_).is_err());
3070 assert!(validate_path_id(NAME, "message id", "msg\\a", where_).is_err());
3071 }
3072
3073 fn append_fresh_opencode_turn(root: &std::path::Path) -> anyhow::Result<()> {
3074 let message_dir = root.join("message").join(FRESH_SESSION_ID);
3075 let part_dir = root.join("part").join(FRESH_MESSAGE_ID);
3076 std::fs::create_dir_all(&message_dir)?;
3077 std::fs::create_dir_all(&part_dir)?;
3078 std::fs::write(
3079 message_dir.join(format!("{FRESH_MESSAGE_ID}.json")),
3080 serde_json::to_vec(&json!({
3081 "id": FRESH_MESSAGE_ID,
3082 "sessionID": FRESH_SESSION_ID,
3083 "role": "user",
3084 "time": { "created": 1759859999000i64 }
3085 }))?,
3086 )?;
3087 std::fs::write(
3088 part_dir.join(format!("{FRESH_PART_ID}.json")),
3089 serde_json::to_vec(&json!({
3090 "id": FRESH_PART_ID,
3091 "sessionID": FRESH_SESSION_ID,
3092 "messageID": FRESH_MESSAGE_ID,
3093 "type": "text",
3094 "text": "fresh opencode text",
3095 "synthetic": false
3096 }))?,
3097 )?;
3098 Ok(())
3099 }
3100
3101 fn write_minimal_session(
3102 root: &std::path::Path,
3103 session_id: &str,
3104 message_id: &str,
3105 ) -> anyhow::Result<()> {
3106 write_json_file(
3107 &root
3108 .join("session")
3109 .join("project")
3110 .join(format!("{session_id}.json")),
3111 &json!({
3112 "id": session_id,
3113 "projectID": "project",
3114 "directory": "/tmp/project",
3115 "time": { "created": 1_765_000_000_000i64, "updated": 1_765_000_000_000i64 },
3116 }),
3117 )?;
3118 write_json_file(
3119 &root
3120 .join("message")
3121 .join(session_id)
3122 .join(format!("{message_id}.json")),
3123 &json!({
3124 "id": message_id,
3125 "sessionID": session_id,
3126 "role": "assistant",
3127 "time": { "created": 1_765_000_000_001i64 },
3128 }),
3129 )?;
3130 std::fs::create_dir_all(root.join("part").join(message_id))?;
3131 Ok(())
3132 }
3133
3134 fn write_json_file(path: &std::path::Path, value: &Value) -> anyhow::Result<()> {
3135 if let Some(parent) = path.parent() {
3136 std::fs::create_dir_all(parent)?;
3137 }
3138 std::fs::write(path, serde_json::to_vec(value)?)?;
3139 Ok(())
3140 }
3141
3142 fn copy_dir(from: &std::path::Path, to: &std::path::Path) -> anyhow::Result<()> {
3143 std::fs::create_dir_all(to)?;
3144 for entry in std::fs::read_dir(from)? {
3145 let entry = entry?;
3146 let source = entry.path();
3147 let target = to.join(entry.file_name());
3148 if entry.file_type()?.is_dir() {
3149 copy_dir(&source, &target)?;
3150 } else {
3151 std::fs::copy(&source, &target)?;
3152 }
3153 }
3154 Ok(())
3155 }
3156
3157 fn insert_session(
3158 conn: &Connection,
3159 id: &str,
3160 directory: &str,
3161 parent: Option<&str>,
3162 agent: Option<&str>,
3163 created: i64,
3164 ) -> anyhow::Result<()> {
3165 conn.execute(
3166 "INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, \
3167 agent, time_created, time_updated) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)",
3168 rusqlite::params![
3169 id,
3170 "proj-test",
3171 parent,
3172 "slug",
3173 directory,
3174 "title",
3175 "1.0.0",
3176 agent,
3177 created
3178 ],
3179 )?;
3180 Ok(())
3181 }
3182
3183 fn insert_message(
3184 conn: &Connection,
3185 id: &str,
3186 session_id: &str,
3187 created: i64,
3188 data: &Value,
3189 ) -> anyhow::Result<()> {
3190 conn.execute(
3191 "INSERT INTO message (id, session_id, time_created, time_updated, data) \
3192 VALUES (?1, ?2, ?3, ?3, ?4)",
3193 rusqlite::params![id, session_id, created, serde_json::to_string(data)?],
3194 )?;
3195 Ok(())
3196 }
3197
3198 fn insert_part(
3199 conn: &Connection,
3200 id: &str,
3201 message_id: &str,
3202 session_id: &str,
3203 created: i64,
3204 data: &Value,
3205 ) -> anyhow::Result<()> {
3206 conn.execute(
3207 "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) \
3208 VALUES (?1, ?2, ?3, ?4, ?4, ?5)",
3209 rusqlite::params![
3210 id,
3211 message_id,
3212 session_id,
3213 created,
3214 serde_json::to_string(data)?
3215 ],
3216 )?;
3217 Ok(())
3218 }
3219
3220 fn write_tree_session(
3223 tree_base: &std::path::Path,
3224 session_id: &str,
3225 marker: &str,
3226 ) -> anyhow::Result<()> {
3227 let message_id = format!("msg_tree_{session_id}");
3228 let part_id_value = format!("prt_tree_{session_id}");
3229 write_json_file(
3230 &tree_base
3231 .join("session")
3232 .join("proj-tree")
3233 .join(format!("{session_id}.json")),
3234 &json!({
3235 "id": session_id,
3236 "projectID": "proj-tree",
3237 "directory": "/tmp/tree",
3238 "time": { "created": 1_780_000_000_000i64 },
3239 }),
3240 )?;
3241 write_json_file(
3242 &tree_base
3243 .join("message")
3244 .join(session_id)
3245 .join(format!("{message_id}.json")),
3246 &json!({
3247 "id": message_id,
3248 "sessionID": session_id,
3249 "role": "assistant",
3250 "time": { "created": 1_780_000_000_001i64 },
3251 }),
3252 )?;
3253 write_json_file(
3254 &tree_base
3255 .join("part")
3256 .join(&message_id)
3257 .join(format!("{part_id_value}.json")),
3258 &json!({
3259 "id": part_id_value,
3260 "sessionID": session_id,
3261 "messageID": message_id,
3262 "type": "text",
3263 "text": marker,
3264 "synthetic": false,
3265 }),
3266 )?;
3267 Ok(())
3268 }
3269}