Skip to main content

pond/adapter/
opencode.rs

1//! opencode adapter (github.com/sst/opencode).
2//!
3//! opencode moved its storage from a JSON file tree to a Drizzle-managed SQLite
4//! database in v1.2.0 (2026-02-14). This adapter reads BOTH, because a user who
5//! upgraded past the format's death still has stranded pre-migration JSON that
6//! never reached the DB (spec.md#session-movement-complete):
7//!
8//! - `<data-dir>/opencode*.db` (WAL) - `session`/`message`/`part` tables. The
9//!   `message`/`part` `data` blob is the old per-file JSON minus its ids;
10//!   opencode rehydrates it as `{...data, id, sessionID(, messageID)}`.
11//! - `<data-dir>/storage/` - the legacy content-addressed split tree:
12//!   `session/<projectID>/<sessionID>.json`, `message/<sessionID>/<messageID>.json`,
13//!   `part/<messageID>/<partID>.json`.
14//!
15//! Sessions are deduped by id (DB wins, the tree fills gaps,
16//! spec.md#adapter-integrity-dedup). Both feed the same
17//! `build_message_events`/`map_part` pipeline, sorted by id (ids are lexically
18//! time-sortable, so id order is creation order), emitting `Session -> Message
19//! -> Parts` per session.
20//!
21//! opencode fuses a tool call and its result into one `tool` part on the
22//! assistant message. Canonical keeps the two apart (a `tool_result` on an
23//! assistant message is a category error, spec.md#model-part-provenance), so the
24//! adapter splits it: a `ToolCall` Part stays on the assistant message and a
25//! synthetic `Tool` message carries the `ToolResult`.
26//!
27//! Restore (native AND foreign) emits one `<session_id>.json` per session in the
28//! `opencode import` shape - `{ info, messages: [ { info, parts } ] }` (see
29//! `packages/opencode/src/cli/cmd/import.ts`). Native replays each real record's
30//! stored `raw_record` (session/message/part) into that envelope and skips the
31//! synthetic split records (the `Tool` carrier message + `ToolResult` part),
32//! re-fusing into the single source `tool` part, so the split is
33//! value-complete-lossless.
34
35use 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
62/// Stateless factory: opens [`OpencodeAdapter`] instances and probes for the
63/// canonical install location under `~/.local/share/opencode/storage`.
64pub 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        // The configured root is the opencode DATA DIR (it holds both the
77        // `opencode*.db` files and the legacy `storage/` tree). Only offer it
78        // when it actually has one of those, so an empty `~/.local/share/opencode`
79        // does not masquerade as a source.
80        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/// Configured opencode reader, rooted at the opencode DATA DIR (which holds the
102/// `opencode*.db` files and the legacy `storage/` tree).
103#[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        // Legacy configs pointed `path` at `<data-dir>/storage`; the root is
112        // the data dir, so a configured basename of `storage` resolves to its
113        // parent and existing configs keep working.
114        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
124/// The legacy split-file tree lives at `<data-dir>/storage/`; a root that IS
125/// itself a bare tree (`session/` directly under it) has no `storage/` subdir,
126/// so fall back to the root. Resolved once per enumeration and carried through
127/// to the read pass.
128fn 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                // The TRUE deduped count: every DB session id plus each legacy-tree
143                // session whose id no DB carries. Superseded tree copies are NOT
144                // counted - the progress bar never ticks them (the bulk Superseded
145                // skip leaves its length untouched).
146                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            // The events_with freshness pre-pass run standalone: the same
157            // per-session peek sync's gate pays every run, classified instead of
158            // read. On an empty oracle the peeks are skipped - a first sync reads
159            // everything. A message-less session stays Opaque (never Empty):
160            // reading it still ingests its Session row. Degrades like the gate: a
161            // source that failed to enumerate already warned and is counted as
162            // whatever survived, never propagated.
163            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            // One blocking burst enumerates both sources, dedups, and (when the
189            // oracle carries watermarks) peeks each session's newest-message
190            // timestamp. It returns owned, Send data so the async gate below can
191            // consult the borrowed oracle without dragging a rusqlite handle
192            // across an await point.
193            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            // Per-source enumeration failures surface as visible errors and the
200            // run continues with the survivors (spec.md#adapter-integrity-no-silent-drops).
201            // A failed DB's session ids are unknown this run, so its tree copies
202            // (if any) ingest undeduped - additive and safe, self-correcting once
203            // the DB opens again next run.
204            for error in errors {
205                yield Err(error);
206            }
207
208            // spec.md#adapter-integrity-dedup: a legacy-tree copy that shares a DB
209            // session's id is a pre-1.2 migration artifact, superseded wholesale by
210            // the DB copy. Content identity is deliberately NOT verified (locked
211            // plan decision): the drop stays visible as a counted Superseded skip -
212            // the spec's sanctioned terminal state for an unresolved collision -
213            // never folded into Empty.
214            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
247/// Re-attribution for a composed opencode reader. A caller that runs opencode's
248/// reader against a foreign root (nanoclaw's opencode-provider sessions) relabels
249/// each yielded session: opencode still owns the main-vs-subagent decision from
250/// its own taxonomy, and the caller supplies only the new `source_agent` root, the
251/// `project` scope, and one `(key, value)` options entry mirrored onto every
252/// session. opencode's own session/message/part ids stay canonical.
253pub(crate) struct Attribution {
254    pub source_agent_root: String,
255    pub project: Extracted<String>,
256    pub extra_options: (String, Value),
257}
258
259/// Stream opencode's `events_with` against `root`, applying `attribution` to every
260/// emitted `Session`; every other yield (messages, parts, skips, errors) passes
261/// through unchanged. Composition-only: the `opencode` adapter's own ingest does
262/// not route through here (it emits its own stream). This seam keeps all opencode
263/// format knowledge in this module so the foreign caller supplies only the
264/// attribution; nanoclaw's provider composition is its one caller today.
265pub(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
280/// Apply an [`Attribution`] to one yield. opencode labels a main session bare
281/// `opencode` and any subagent `opencode/<agent>` or `opencode/subagent`, so a
282/// non-bare label is exactly opencode's "this is a subagent" signal.
283fn 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
298/// A discovered session tagged by source, plus its freshness watermark peek in
299/// micros (`None` = not peeked or unreadable -> re-read to be safe).
300struct HeadEntry {
301    source: SessionSource,
302    source_ts: Option<i64>,
303}
304
305/// Where a session's records come from: a SQLite DB (identified by id, its full
306/// row fetched at read time) or the legacy split tree.
307enum 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
321/// A DB session located by the light enumeration SELECT: just the DB it lives in
322/// and its id. The full `session` row is fetched by primary key at read time so
323/// the freshness gate never materializes a row it may skip.
324struct DbLight {
325    db_path: PathBuf,
326    session_id: String,
327}
328
329/// A session read from a DB: the canonical `Session` (built from the row
330/// columns) plus the DB path needed to re-open for the body read.
331struct DbSessionHead {
332    db_path: PathBuf,
333    session: Session,
334}
335
336/// The enumerated (and optionally peeked) session set, plus the once-resolved
337/// tree base carried through to the read pass and any per-source enumeration
338/// failures collected for the gate to surface.
339struct Peeked {
340    tree_base: PathBuf,
341    entries: Vec<HeadEntry>,
342    /// Tree sessions whose id a DB already carries (DB wins); counted, not read.
343    duplicates: usize,
344    errors: Vec<AdapterError>,
345}
346
347/// Enumerate both sources into light entries, dedup by session id (every DB
348/// session id, plus the legacy-tree sessions no DB carries,
349/// `adapter-integrity-dedup`), and - when `peek` - compute each session's
350/// freshness watermark. A single connection per DB is opened for the id SELECT
351/// and reused for every one of that DB's session peeks (no double open). Tree
352/// walks cache their listings on the `SessionFile` so the read pass does not
353/// re-list.
354///
355/// Error resilience (spec.md#adapter-integrity-no-silent-drops), two levels:
356/// - a per-session watermark peek that fails degrades to `source_ts = None`
357///   (never fresh, so a safe re-read), not propagated;
358/// - a per-source failure (a DB that won't open, its id SELECT fails, or an
359///   unlistable tree) is `warn!`-logged naming the source and collected into
360///   `errors`, and enumeration continues with the remaining sources.
361fn 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    // DB side: light `(db_path, session_id)` per session, resilient per source.
367    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    // Tree side: list session files and dedup against the DB ids.
400    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
466/// Read every survivor session's body, streaming events through `tx`. Opens each
467/// DB once (cached by path); tree sessions route through the legacy read path.
468/// Keeps its OWN connection cache: the enumerate/peek pass's handles are dropped
469/// before this runs, so no rusqlite handle is dragged across the await between
470/// them.
471fn 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
488/// Fetch one DB survivor's full `session` row by primary key (the peek carried
489/// only the id), build its head, and stream its body. A row that vanished between
490/// enumeration and read (opencode deleted the session; our separate statements
491/// share no snapshot) yields a typed error for that session and continues
492/// (spec.md#adapter-integrity-no-silent-drops).
493fn read_db_survivor(
494    conns: &mut HashMap<PathBuf, Connection>,
495    light: DbLight,
496    tx: &mpsc::Sender<Result<AdapterYield, AdapterError>>,
497) -> bool {
498    // DB session ids become restore filenames; a hostile id fails here at
499    // ingest, typed and attributed, like the tree path's four gates.
500    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
526/// `NAME`-bound view of the shared [`sqlite`] plumbing (one impl, two adapters).
527fn join_error(join: tokio::task::JoinError) -> AdapterError {
528    sqlite::join_error(NAME, join)
529}
530
531/// One session file located on disk. `cached_subtree` is populated only when
532/// the freshness pre-walk happened (i.e. the oracle had a watermark for this
533/// session); the read pass reuses the listings instead of re-walking.
534struct SessionFile {
535    session_id: String,
536    path: PathBuf,
537    cached_subtree: Option<SubtreeWalk>,
538}
539
540/// Result of one subtree walk: the message and part directory listings (so the
541/// read pass doesn't redo them). The last `message_files` entry is the session's
542/// latest message id for the freshness check.
543struct SubtreeWalk {
544    message_files: Vec<PathBuf>,
545    /// One entry per message file, in the same order; each is the sorted list
546    /// of part files for that message. Empty vec = message has no parts.
547    part_files_by_message: Vec<Vec<PathBuf>>,
548}
549
550/// Walk `<root>/session/<projectID>/<sessionID>.json`, sorted for deterministic
551/// ingest order. A missing `session/` dir means "no sessions yet", not an error.
552fn 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
601/// Walk one session's full subtree: the message files under `message/<sid>/` and
602/// every part file under `part/<mid>/`, returning the listings so the read pass
603/// can reuse them.
604fn 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
629/// Watermark for the freshness gate: the session's max stored message timestamp,
630/// read from the last message's subtree (its `time.created` raised by any of its
631/// tool parts' `state.time.end`, via [`watermark_micros`]). Earlier messages'
632/// events all precede the last message, so its subtree suffices. `None` on an
633/// empty session or unreadable last message -> safe re-read. Reads only the last
634/// message and its parts (a handful of small files).
635fn 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
646/// The freshness-watermark kernel (micros): the message's `data.time.created`,
647/// raised by each part's `state.time.end`; `.timestamp_micros()`. `None` (the
648/// message carries no `time.created`) = safe re-read.
649///
650/// KNOWN RESIDUAL: the peek reads only the newest message's subtree, while pond's
651/// stored watermark is a global max over ALL messages, including the synthetic
652/// tool carrier messages stamped at their `state.time.end`. So an earlier
653/// message's tool end outrunning a later message's `time.created` (clock skew,
654/// out-of-order end) can over-skip. Accepted: ULID message ordering plus
655/// opencode's turn protocol make it unreachable in practice, and `pond sync
656/// --verify` is the full-re-read backstop (the same posture as the equal-micros
657/// residual documented on `SkipOracle` in adapter/mod.rs).
658fn 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
668/// Returns `false` when the consumer dropped the receiver and the read should stop.
669fn 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    // Reuse the freshness pre-walk's listings when present; otherwise list now.
702    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
777/// The `session` table's columns as a SELECT list. Read by name in
778/// [`session_info_from_row`], so this order is NOT load-bearing - it only fixes
779/// the projected column set.
780const 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
786/// List the `*.<extension>` files directly in `dir` whose file name passes
787/// `name_filter`, sorted by path (= creation order, ids are time-sortable). A
788/// missing dir is an empty list, not an error - a source dir may not exist yet.
789fn 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
819/// Every `opencode*.db` directly under `root` (WAL `-wal`/`-shm` sidecars are
820/// excluded by the `.db` extension filter). A missing root is "no DB", not an
821/// error - the adapter may be pointed at a bare legacy tree.
822fn db_paths(root: &Path) -> Result<Vec<PathBuf>, AdapterError> {
823    list_files_sorted(root, "db", |name| name.starts_with("opencode"))
824}
825
826/// Test-only direct open (the non-test path opens through [`connection`]).
827#[cfg(test)]
828fn open_db(path: &Path) -> Result<Connection, AdapterError> {
829    sqlite::open_db(NAME, path)
830}
831
832/// DB-interaction failures route through [`sqlite::db_error`] as
833/// [`AdapterError::io`]; genuine shape errors route through
834/// [`reconstruct_record`]'s parse errors instead.
835fn db_error(path: &Path, op: &str, error: &rusqlite::Error) -> AdapterError {
836    sqlite::db_error(NAME, path, op, error)
837}
838
839/// Get-or-open a cached connection for `path`.
840fn 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
847/// Every session id in one DB, ordered by id for deterministic ingest. Uses the
848/// shared connection cache so enumeration and the per-session peeks share a
849/// single open per DB.
850fn 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
865/// Fetch one `session` row by primary key and map it to a [`DbSessionHead`].
866/// `Ok(None)` when no row matches (the session vanished between enumeration and
867/// read - our separate statements share no snapshot).
868fn 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
885/// Map one reconstructed `SessionInfo` to a canonical [`Session`]. Reusing
886/// [`session_from_value`] threads `directory` through the sealed `Extracted` path
887/// and derives `created_at` from the row `time.created`.
888fn db_session_head(db_path: &Path, info: &Value) -> Result<DbSessionHead, AdapterError> {
889    let mut session = session_from_value(info, db_path)?;
890    // spec.md#model-no-synthesis: a subagent session (`parentID` set) is labeled
891    // `opencode/<agent>` so children leave default search, matching claude-code; a
892    // null agent column degrades to the bare `opencode/subagent` label. Tree-era
893    // child sessions carry no agent column and keep the plain `opencode` the tree
894    // path assigns: `source_agent` is immutable after first write
895    // (spec.md#adapter-integrity-additive-sync), so relabeling would fork history
896    // against already-stored rows.
897    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
909/// Rebuild the `fromRow` `SessionInfo` JSON from a `session` row by NAME
910/// (`&str` implements `RowIndex`): camelCase keys, nested `time`/`tokens`/`summary`,
911/// null columns omitted (spec.md#model-lossless-projection - every non-null column
912/// is recoverable). JSON-mode columns (model, metadata, permission, revert,
913/// summary diffs) go through [`json_or_string`]. This is the shape
914/// [`session_from_value`] and the DB conformance test both consume.
915fn 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
1011/// Freshness watermark for a DB session (micros): the newest message by id order,
1012/// its `data.time.created` raised by any of its tool parts' `state.time.end` (via
1013/// [`watermark_micros`]). NEVER the `time_created` COLUMN - migration-stamped rows
1014/// carry a future column value that would re-read forever.
1015/// `None` (empty or unreadable newest message) -> safe re-read.
1016///
1017/// The probe is two statements on purpose: the newest id comes from a covering
1018/// scan of the `(session_id, time_created, id)` index (ids only - the sort
1019/// discards every row but one, so materializing `data` per row would read the
1020/// session's whole `data` column just to keep one blob), then that single
1021/// row's `data` is fetched by primary key. The only streamable alternative
1022/// order is `time_created` (the poisoned migration column), so this MUST NOT
1023/// be "optimized" onto it.
1024fn 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    // A row vanishing between the two statements (live writer) is a safe
1047    // re-read, same as an unparseable one.
1048    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
1069/// Owned projection of one `message`/`part` row: its id (for ordering and id
1070/// injection) and its `data` blob. The row `time_created` column is deliberately
1071/// not projected (see the `default_ts` note in [`read_db_session`]).
1072struct 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
1099/// Read one DB session's body in short bursts (messages, then per-message parts -
1100/// each query its own autocommit read, never one long transaction), feeding the
1101/// same `build_message_events` pipeline as the tree path. Returns `false` when
1102/// the consumer dropped the receiver.
1103fn 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    // No `ORDER BY id` in SQL: no index ends in bare `id`, so an SQL sort forces a
1117    // transient temp b-tree over the full rows (~2x memory), and chunked keyset
1118    // pagination would be O(N^2) re-sorts. The accepted bound is O(one session)
1119    // resident, sorted in Rust below (BINARY collation == Rust byte order,
1120    // verified no `COLLATE` on `id`).
1121    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        // spec.md#model-no-synthesis: `build_message_events` prefers the message's
1139        // own `data.time.created`; when it is absent the session anchor is the
1140        // spec-blessed fallback. The row `time_created` COLUMN is deliberately NOT
1141        // used: it is migration-stamped, and a future column value would become the
1142        // stored watermark and freshness-skip every later real message. Trade-off:
1143        // a non-migrated message genuinely lacking `data.time.created` takes the
1144        // coarser session anchor even though its column was accurate - accepted
1145        // because ordering still survives via the id tiebreaker.
1146        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        // The part query KEEPS its `ORDER BY id`: it streams via the
1162        // (message_id, id) index (verified via EXPLAIN QUERY PLAN), no temp b-tree.
1163        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
1205/// Reconstruct a `message`/`part` value as `{...data, <injected>}`, delegating the
1206/// cap check, parse, and leaf bounding to [`parse_bounded`] before injecting the
1207/// row's ids. A malformed `data` JSON drops only that record, like a malformed
1208/// part file in the tree. `location` is formatted lazily (only on the error path).
1209fn 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
1234/// Read one JSON file, bounding every string leaf at the seam cap
1235/// (spec.md#adapter-bounded-values) before it leaves this module. The size gate
1236/// runs on metadata BEFORE the read so the record cap stays a memory bound,
1237/// never a post-hoc validation of bytes already resident.
1238fn 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
1252/// List `*.json` files in `dir`, sorted by filename (= creation order, ids are
1253/// time-sortable). A missing dir is an empty list - a message can legitimately
1254/// carry no parts.
1255fn 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    // spec.md#model-project-non-empty: opencode always records `directory` (the
1270    // project cwd); its absence is a malformed session, not a default.
1271    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        // opencode sub-sessions (a Task spawn) carry `parentID`; a soft
1279        // reference, present only when this session was spawned from another.
1280        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
1292/// Build the ordered event stream for one message: the message, its parts in
1293/// order, then any synthetic `Tool` messages (one per `tool` part) each
1294/// followed by its `ToolResult`.
1295fn 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        // opencode v2 carries non-conversational message roles (synthetic,
1323        // shell, compaction); keep them as System carriers rather than drop
1324        // them (spec.md#adapter-integrity-no-silent-drops). The raw record
1325        // survives in options; the role label is the content.
1326        _ => 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
1355/// One mapped source part: the canonical Part it becomes, plus - for a fused
1356/// `tool` part - the synthetic `Tool` message and `ToolResult` it splits off.
1357struct 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        // patch / step-start / step-finish (and any other marker) are
1392        // harness-produced turn machinery, not conversation: keep them as
1393        // injected Parts whose `raw_record` round-trips the source file.
1394        _ => (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
1411/// spec.md#model-part-provenance: opencode marks harness-injected text parts
1412/// (the `Called the <tool> tool ...` echo, auto-expanded `@file` content) with
1413/// `synthetic: true`; a genuine prompt or model reply is `synthetic: false`.
1414fn 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    // spec.md#model-no-synthesis: an absent mime hint is faithfully `None`,
1436    // not a synthesized `application/octet-stream` placeholder.
1437    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
1456/// Split one opencode `tool` part (call + result fused) into a `ToolCall` on the
1457/// owning assistant message and a synthetic `Tool` message carrying the
1458/// `ToolResult`. The `ToolCall` keeps the source part's id and stores its full
1459/// `raw_record`, so native restore reproduces the single source file; the
1460/// synthetic records carry no `raw_record` and are skipped on restore.
1461fn 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    // Take input/output by moving them out of a single owned `state` clone
1476    // rather than cloning each field separately - a fused tool part fans into
1477    // three records (call + tool message + result) that all draw on `state`.
1478    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                    // No output/error - the rest of `state` IS the payload.
1487                    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        // spec.md#model-part-provenance: the model authored the tool call.
1500        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        // spec.md#model-part-provenance: tool output is runtime-produced.
1523        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
1547/// Marks a canonical record the adapter synthesized (the `Tool` message and
1548/// `ToolResult` split off a fused `tool` part). Native restore skips records so
1549/// marked - they correspond to no source file.
1550fn 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    // Native replays each stored `raw_record` into the `opencode import`
1576    // envelope: the session `info`, then each real message's `raw_record` as an
1577    // `info` with its real parts' `raw_record`s nested under it. The synthetic
1578    // `Tool` message and `ToolResult` (which carry no `raw_record`) are skipped,
1579    // re-fusing into the single source `tool` part. Replay echoes a frozen
1580    // snapshot - safe only while canonical is append-only
1581    // (spec.md#adapter-integrity-additive-sync). One file per session; a child
1582    // session is its own `SessionWithMessages`, so the caller iterating every
1583    // session id emits parent and child files independently
1584    // (spec.md#adapter-lineage-complete-restore).
1585    //
1586    // spec.md#adapter-native-restore-lossless: when the session lacks a stored
1587    // `raw_record` (older ingest, foreign-sourced session), native is
1588    // impossible. We downgrade to foreign and stamp `actual_fidelity` so the
1589    // caller can surface the downgrade instead of getting a silent surprise.
1590    let Some(session_raw) = raw_record(&session.session.options) else {
1591        return serialize_foreign(session);
1592    };
1593
1594    // Deterministic order: timestamp, then id (peers use the shared comparator).
1595    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        // A non-synthetic message with no stored `raw_record` cannot be replayed
1604        // natively; downgrade the whole session to foreign (the same fallback the
1605        // session-level `raw_record` check above applies) rather than silently
1606        // dropping the message from the envelope.
1607        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            // A real part carries its source `raw_record`; the synthetic split
1613            // `ToolResult` does not, so it is skipped here.
1614            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    // Foreign restore: a best-effort, idiomatic `opencode import` envelope. A
1633    // non-opencode session has no `projectID` hash, so derive a stable key from
1634    // the project path; tool results (canonical `Tool` messages) and System
1635    // carriers have no idiomatic home in opencode's message model and are
1636    // dropped (spec.md#adapter-native-restore-lossless, foreign clause). The
1637    // minimal `info` shapes omit fields canonical does not carry (a user
1638    // message's `agent`/`model`) - the same loss foreign restore accepts.
1639    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    // Deterministic order: timestamp, then id (peers use the shared comparator).
1648    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            // No idiomatic opencode home; content stays in canonical.
1657            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
1683/// Assemble the `opencode import` envelope (`{ info, messages }`) both serializers
1684/// produce and write it as the single `<session_id>.json` restore file.
1685fn 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        // ToolResult / approval parts have no standalone opencode shape.
1735        _ => 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    //! End-to-end test for the opencode adapter: ingest the committed
1765    //! split-file fixture corpus and assert pond's canonical shape comes out
1766    //! the other side, including the fused-tool-part split. The fixture lives
1767    //! under `tests/fixtures/adapter/opencode/storage/`.
1768    #![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    // Manifest-dir anchored: unit tests must not depend on the process cwd
1780    // (figment::Jail chdirs the whole test process while config tests run).
1781    // `FIXTURES` is the legacy split-file tree (used to exercise the tree path in
1782    // isolation); `DATA_DIR` is the opencode data dir holding BOTH the DB and the
1783    // tree beside it.
1784    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    // Facts about the committed DB fixture.
1801    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    // The doctored row's `data.time.created` (truth) vs its future `time_created`
1807    // COLUMN (the migration-stamp quirk).
1808    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    /// Copy the DB fixture into a fresh temp data dir (no `storage/` tree beside
1812    /// it, so ingest is DB-only). Returns the data dir path.
1813    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    /// probe_default returns the DATA DIR (not the `storage/` subdir) and only
1832    /// when it actually holds a DB or a tree.
1833    #[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        // An empty data dir (no DB, no tree) is not a source.
1848        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    /// The `opencode import` envelope a native serialize must produce for one tree
1857    /// session, derived INDEPENDENTLY from the fixture tree files (session json +
1858    /// message/ + part/ files) - not from any production serializer internal.
1859    /// Mirrors the DB conformance test's derive-from-raw approach: message order
1860    /// matches the serializer's `by_timestamp_then_id` (time.created, then id),
1861    /// parts stay in id/filename order, and every leaf is bounded via
1862    /// `extract_raw_record` (identity on the small fixture, faithful in general).
1863    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        // Locate session/<project>/<id>.json.
1872        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    /// Serialize `session` native and return the single emitted file's parsed
1907    /// JSON body, asserting the import-shape envelope (`<id>.json`, one file).
1908    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    /// A legacy-tree-era session serializes native into the import envelope with
1928    /// the tree `raw_record`s embedded.
1929    #[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            // No synthetic split record leaks into the envelope: every emitted
1952            // message id is a real (non-synthetic) canonical message.
1953            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    /// Native restore against the DB fixture: for every emitted file, the
1971    /// envelope `info`/messages/parts value-equal the rows reconstructed straight
1972    /// from the DB (`session_info_from_row` / `reconstruct_record`), synthetic
1973    /// split records are absent, and every session - parent AND child - gets its
1974    /// own file (spec.md#adapter-lineage-complete-restore).
1975    #[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        // Independently reconstruct the expected records from the raw DB rows.
1989        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            // Session info matches the row-reconstructed SessionInfo.
2022            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            // Message + part bodies match the rows' reconstructed JSON.
2031            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            // Synthetic split records are absent: the envelope's message ids are
2077            // exactly the DB message rows (no `<part>/result` carriers).
2078            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    /// Shared assertion for `plan_matches_the_events_gate*`: a first sync (empty
2104    /// oracle) is all-pending, then a max-watermark oracle is re-planned and its
2105    /// fresh count is asserted to equal the `events_with` gate's fresh count.
2106    /// Returns `(first_sync, max_plan)` so each caller adds its scenario-specific
2107    /// count assertions (all-fresh DB vs. one message-less pending in the tree).
2108    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    /// `plan` is the events_with freshness pre-pass run standalone and MUST
2152    /// agree with it: the sessions plan calls fresh are exactly the sessions
2153    /// the gate skips. A message-less session stays pending (never Empty) -
2154    /// reading it still ingests its Session row, so the gate must keep
2155    /// re-reading it.
2156    #[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    /// `append_fresh_opencode_turn` writes its message at this `time.created`
2181    /// (millis); the freshness gate keys on it in micros.
2182    const FRESH_TURN_MICROS: i64 = 1_759_859_999_000 * 1_000;
2183
2184    /// A session whose latest message is newer than the watermark is re-read, and
2185    /// the appended turn lands.
2186    #[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        // Watermark sits just below the appended message's timestamp.
2198        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    /// A session whose latest message is no newer than the watermark is skipped as
2224    /// `Fresh` - the appended turn is NOT re-read.
2225    #[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        // Watermark at/above the appended timestamp: the session is fresh.
2237        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        // DATA_DIR ingests BOTH sources (the DB plus the tree beside it).
2370        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    /// The synthetic `Tool` message a `tool` part splits off must carry a
2433    /// `Tool` role with one `ToolResult`, and must NOT collide with or
2434    /// overwrite the assistant message that owns the `ToolCall`.
2435    #[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        // The archived + multi-project sessions all land (census sanity), and the
2521        // child session's canonical shape is asserted in its own test.
2522        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    /// The doctored row's canonical message timestamp is `data.time.created`, and
2563    /// the session's freshness watermark ignores the future `time_created` COLUMN.
2564    #[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        // The freshness watermark also ignores the column: it is derived from the
2593        // data timestamps (all months before the doctored column value).
2594        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    /// Part types absent from the generated corpus (subtask/compaction/agent/
2606    /// snapshot) land as injected carriers whose `raw_record` round-trips.
2607    #[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            // The standalone carrier session references no `project` row; the
2614            // adapter reads the DB, it does not enforce opencode's FKs, so the
2615            // fixture writer disables them for this hand-authored session.
2616            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    /// A session id present in BOTH the DB and the legacy tree is emitted once
2700    /// (the DB copy wins) and the deduped tree copy is counted, not silent.
2701    #[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        // A tree that overlaps one DB session id and adds one tree-only session.
2706        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        // A legacy config points at `<data-dir>/storage`; normalization resolves
2811        // it to the parent, where the DB lives.
2812        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    /// `plan` agrees with the `events_with` gate on the DB source (port of the
2824    /// tree test): first sync is all-pending, a max watermark makes every session
2825    /// fresh, and the gate skips exactly those.
2826    #[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    /// Foreign restore of a non-opencode (pi) session emits one `<id>.json` in
2981    /// the `opencode import` envelope: `{info, messages:[{info, parts}]}` built
2982    /// from canonical fields, with a re-fused `tool` part (`state.status =
2983    /// completed`, `input`). Per spec 6.8 there is no re-parse path for import
2984    /// JSON, so structural + value assertions suffice (golden-file reviewed).
2985    #[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            // Defense-in-depth: the writer accepts the emitted path.
3013            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    /// Write a minimal legacy-tree session (one assistant message, one text part
3221    /// carrying `marker`) under `tree_base`.
3222    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}