Skip to main content

pond/adapter/
nanoclaw.rs

1//! NanoClaw adapter (github.com/nanocoai/nanoclaw).
2//!
3//! NanoClaw runs agents in containers and has NO transcript format of its own
4//! for the Claude provider: the container runs the Claude Agent SDK, which
5//! writes standard Claude-Code JSONL. So nanoclaw IS a claude-JSONL source and
6//! reuses `claude_code`'s record mapping verbatim ([`map_row_events`],
7//! [`claude_peek_watermark`], the subagent-sidecar helpers, [`claude_serialize`]);
8//! only session identity, the on-disk layout, and the SQLite metadata join
9//! differ.
10//!
11//! Layout (under the install root, `config { "path": "<root>" }`):
12//! ```text
13//! <root>/data/v2.db                                    central metadata DB
14//! <root>/data/v2-sessions/<agentGroupId>/
15//!   .claude-shared/projects/<projectDir>/<sdkUuid>.jsonl            transcripts
16//!   .claude-shared/projects/<projectDir>/<sdkUuid>.jsonl.rotated-<epochMs>  rotated (immutable, complete)
17//!   .claude-shared/projects/<projectDir>/<sdkUuid>/subagents/agent-<id>.jsonl  subagent transcripts (+ .meta.json)
18//!   <sessionId>/outbound.db                            container-written (session_state)
19//!   <sessionId>/inbound.db                             host-written queue
20//!   <sessionId>/opencode-xdg/                          opencode-provider storage (composed via the opencode reader)
21//! ```
22//!
23//! Identity (plan 3.1): `session_id` = SDK transcript filename stem; subagent
24//! sidecars are their own sessions per `claude_code` convention. `project` =
25//! `<agentGroupId>` (the group is nanoclaw's shared-state scope; container `cwd`
26//! is always `/workspace/agent`, so it carries no per-session identity).
27//! `source_agent` = `nanoclaw` / `nanoclaw/subagent`. `queue-operation` records
28//! (no uuid/message) ride placement rule 3 as System carriers, never dropped -
29//! the same no-`message` path `claude_code` uses for its own metadata rows.
30//!
31//! Metadata join (plan 1.3, read-only, best-effort): each `<sessionId>/outbound.db`
32//! `session_state` key `continuation:claude` (legacy `sdk_session_id`) links a
33//! transcript SDK uuid to a nanoclaw session id; `data/v2.db` then joins
34//! `sessions` -> `messaging_groups` -> `agent_groups` -> `container_configs`. The
35//! result lands in `options.nanoclaw`. DBs open read-only with a busy timeout and
36//! retry once on a malformed image; any unreadable DB, absent column, or orphan
37//! (in either direction) degrades to metadata-absent - the transcript still
38//! ingests, and nothing is ever synthesized.
39//!
40//! Providers (plan 1.4, 3.4): the base runtime ships the Claude provider; codex
41//! and opencode are opt-in per-group skills. This adapter handles all three.
42//! Claude rides the `JsonlTree` path above. For each session folder holding an
43//! `opencode-xdg/` store, the `opencode` adapter's reader runs against it
44//! (composition, not a second parser) and each session is re-attributed:
45//! `source_agent` -> `nanoclaw` / `nanoclaw/subagent` (opencode's own taxonomy
46//! decides which), `project` -> the `<agentGroupId>`, provider + nanoclaw metadata
47//! -> `options.nanoclaw`; opencode's own session/message ids stay canonical. Codex
48//! keeps its history server-side with no on-disk transcript, so sessions whose
49//! resolved provider (`v2.db sessions.agent_provider`, else the group's
50//! `container_configs.provider`, else `claude`) is `codex` surface as a visible
51//! `Unsupported` skip, enumerated from `v2.db` metadata.
52//!
53//! Documented non-ingest (per-adapter contract): the pre-compaction Markdown
54//! summaries under `groups/<folder>/conversations/*.md`; the `messages_in` /
55//! `messages_out` IPC bodies in `inbound.db` / `outbound.db` (routing, not
56//! transcript); the `tool-results/*.txt` spilled tool outputs (referenced by, not
57//! part of, the transcript); and codex-provider sessions (server-side history,
58//! surfaced as a counted skip, never ingested).
59
60use std::collections::HashMap;
61use std::ffi::OsStr;
62use std::path::{Path, PathBuf};
63use std::sync::{Arc, OnceLock};
64
65use async_stream::stream;
66use rusqlite::{Connection, OptionalExtension};
67use serde_json::{Value, json};
68use tokio_stream::StreamExt;
69
70use crate::{
71    sessions::IngestEvent,
72    wire::{ProviderOptions, Session},
73};
74
75use super::{
76    Adapter, AdapterError, AdapterFactory, AdapterYield, AdapterYieldStream, DiscoverFuture, Env,
77    PlanFuture, RestoreFidelity, RestoredFile, SkipOracle, SkipReason, SourceWatermark,
78    claude_code::{
79        FileState, SubagentDescriptor, claude_peek_watermark, claude_serialize,
80        is_workflow_control_file, map_row_events, parse_timestamp, source_project_dir,
81        subagent_descriptor, subagent_ids, subagent_unsupported_reason, subagents_dir,
82        unresolved_subagent_error,
83    },
84    config_path,
85    extract::{Extracted, extract_self_str},
86    jsonl::{BoundedRow, JsonlTree, jsonl_tree_discover, jsonl_tree_events, jsonl_tree_plan},
87    opencode, sqlite, validate_path_id,
88};
89
90const NAME: &str = "nanoclaw";
91
92/// Install-root candidate `probe_default` checks. Installs live anywhere, so
93/// config-first is the normal path; the probe only offers `~/nanoclaw` as a
94/// convenience and is gated on it actually holding `data/v2-sessions/`.
95const PROBE_CANDIDATES: &[&[&str]] = &[&["nanoclaw"]];
96
97/// Stateless factory: opens [`NanoclawAdapter`] instances and probes the
98/// well-known install locations for a `data/v2-sessions/` dir.
99pub struct NanoclawFactory;
100
101impl AdapterFactory for NanoclawFactory {
102    fn name(&self) -> &'static str {
103        NAME
104    }
105
106    fn open(&self, config: Value) -> Result<Box<dyn Adapter>, AdapterError> {
107        Ok(Box::new(NanoclawAdapter::new(config_path(NAME, config)?)))
108    }
109
110    fn probe_default(&self, env: &Env) -> Option<Value> {
111        // Installs live anywhere, so config-first is the normal path; auto-probe
112        // only offers a candidate that actually holds `data/v2-sessions/`, so an
113        // empty checkout never masquerades as a source.
114        for segments in PROBE_CANDIDATES {
115            let mut candidate = env.home.clone();
116            for segment in *segments {
117                candidate.push(segment);
118            }
119            if candidate.join("data").join("v2-sessions").is_dir() {
120                return Some(json!({ "path": candidate }));
121            }
122        }
123        None
124    }
125
126    fn serialize(
127        &self,
128        session: &crate::sessions::SessionWithMessages,
129        fidelity: RestoreFidelity,
130    ) -> Result<Vec<RestoredFile>, AdapterError> {
131        claude_serialize(NAME, session, fidelity, transcript_path(session)?)
132    }
133}
134
135/// Configured nanoclaw reader. Walks `<root>/data/v2-sessions/.../*.jsonl`
136/// (plus rotated files) and joins each transcript to its nanoclaw metadata,
137/// built lazily on first read so `plan`/`discover` never pay for the DB scan.
138#[derive(Clone)]
139pub struct NanoclawAdapter {
140    root: PathBuf,
141    metadata: Arc<OnceLock<HashMap<String, Value>>>,
142}
143
144impl NanoclawAdapter {
145    pub fn new(root: impl Into<PathBuf>) -> Self {
146        Self {
147            root: root.into(),
148            metadata: Arc::new(OnceLock::new()),
149        }
150    }
151
152    fn metadata(&self) -> &HashMap<String, Value> {
153        self.metadata.get_or_init(|| build_metadata(&self.root))
154    }
155}
156
157impl Adapter for NanoclawAdapter {
158    fn discover(&self) -> DiscoverFuture<'_> {
159        jsonl_tree_discover(self)
160    }
161
162    fn events_with<'a>(&'a self, oracle: &'a dyn SkipOracle) -> AdapterYieldStream<'a> {
163        // Claude transcripts ride the JsonlTree path; opencode-provider stores and
164        // codex sessions are enumerated from `data/v2-sessions` + `v2.db` and
165        // appended (composition + visible skips), so the JsonlTree fast path stays
166        // untouched.
167        let claude = jsonl_tree_events(self, oracle);
168        let root = self.root.clone();
169        Box::pin(stream! {
170            let mut claude = claude;
171            while let Some(item) = claude.next().await {
172                yield item;
173            }
174
175            let work = tokio::task::spawn_blocking(move || provider_work(&root)).await;
176            let work = match work {
177                Ok(work) => work,
178                Err(join) => {
179                    yield Err(AdapterError::io(
180                        NAME,
181                        "provider enumeration task",
182                        std::io::Error::other(join.to_string()),
183                    ));
184                    return;
185                }
186            };
187
188            for skip in work.codex_skips {
189                yield Ok(AdapterYield::Skipped {
190                    session_id: Some(skip.session_id),
191                    project: Some(skip.agent_group_id),
192                    reason: SkipReason::Unsupported(CODEX_SKIP_REASON.to_owned()),
193                });
194            }
195
196            for session in work.opencode {
197                let attribution = opencode::Attribution {
198                    source_agent_root: NAME.to_owned(),
199                    project: session.project,
200                    extra_options: ("nanoclaw".to_owned(), session.nanoclaw_options),
201                };
202                let mut composed =
203                    opencode::composed_events_with(session.data_dir, oracle, attribution);
204                while let Some(item) = composed.next().await {
205                    yield item;
206                }
207            }
208        })
209    }
210
211    fn plan<'a>(&'a self, oracle: &'a dyn SkipOracle) -> PlanFuture<'a> {
212        jsonl_tree_plan(self, oracle)
213    }
214}
215
216impl JsonlTree for NanoclawAdapter {
217    type State = FileState;
218
219    fn name(&self) -> &'static str {
220        NAME
221    }
222
223    fn root(&self) -> &Path {
224        &self.root
225    }
226
227    fn is_transcript(&self, path: &Path) -> bool {
228        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
229            return false;
230        };
231        name.ends_with(".jsonl") || is_rotated_transcript(name)
232    }
233
234    fn skip_source(&self, path: &Path) -> bool {
235        is_workflow_control_file(path)
236            || path
237                .components()
238                .any(|component| component.as_os_str() == OsStr::new("opencode-xdg"))
239    }
240
241    fn peek_session_id(&self, path: &Path, _first_line: &str) -> Option<String> {
242        if subagents_dir(path).is_some() {
243            let (parent_uuid, child_suffix, _) = subagent_ids(path)?;
244            return Some(format!("{parent_uuid}/{child_suffix}"));
245        }
246        sdk_session_stem(path)
247    }
248
249    fn peek_watermark(&self, path: &Path) -> SourceWatermark {
250        claude_peek_watermark(path)
251    }
252
253    fn session(&self, path: &Path, rows: &[BoundedRow]) -> Result<Session, AdapterError> {
254        session_from_rows(path, rows, self.metadata())
255    }
256
257    fn events_from_row(
258        &self,
259        session: &Session,
260        row: &BoundedRow,
261        state: &mut Self::State,
262    ) -> Result<Vec<IngestEvent>, String> {
263        map_row_events(&session.id, session.created_at, row, state)
264    }
265
266    fn unsupported_reason(&self, path: &Path) -> Option<String> {
267        subagent_unsupported_reason(path)
268    }
269}
270
271/// `<uuid>.jsonl.rotated-<epochMs>` - a rotated-out complete transcript. Its
272/// extension is not `jsonl`, so the default walk skips it; nanoclaw accepts it.
273fn is_rotated_transcript(name: &str) -> bool {
274    match name.rsplit_once(".rotated-") {
275        Some((head, digits)) => {
276            head.ends_with(".jsonl")
277                && !digits.is_empty()
278                && digits.bytes().all(|b| b.is_ascii_digit())
279        }
280        None => false,
281    }
282}
283
284/// The SDK session uuid from a transcript filename: the stem, with a trailing
285/// `.rotated-<epochMs>` stripped first so a rotated file ingests under the same
286/// id as the live file it was renamed from.
287fn sdk_session_stem(path: &Path) -> Option<String> {
288    let name = path.file_name()?.to_str()?;
289    let base = match name.rsplit_once(".rotated-") {
290        Some((head, digits))
291            if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) =>
292        {
293            head
294        }
295        _ => name,
296    };
297    base.strip_suffix(".jsonl").map(ToOwned::to_owned)
298}
299
300/// The `<agentGroupId>` directory name - the path component right after
301/// `v2-sessions`. This is nanoclaw's shared-state scope and pond's `project`.
302fn agent_group_from_path(path: &Path) -> Option<String> {
303    let mut components = path.components();
304    while let Some(component) = components.next() {
305        if component.as_os_str() == OsStr::new("v2-sessions") {
306            return components
307                .next()
308                .and_then(|next| next.as_os_str().to_str())
309                .map(ToOwned::to_owned);
310        }
311    }
312    None
313}
314
315fn session_from_rows(
316    path: &Path,
317    rows: &[BoundedRow],
318    metadata: &HashMap<String, Value>,
319) -> Result<Session, AdapterError> {
320    let display = path.display().to_string();
321    // A non-agent leaf under `subagents/` would borrow the parent's content
322    // `sessionId` and silently merge; refuse structurally.
323    if let Some(error) = unresolved_subagent_error(NAME, path) {
324        return Err(error);
325    }
326
327    let agent_group_id = agent_group_from_path(path).ok_or_else(|| {
328        AdapterError::schema(
329            NAME,
330            display.clone(),
331            "transcript path is not under data/v2-sessions/<agentGroupId>",
332        )
333    })?;
334    let subagent = subagent_descriptor(path);
335    let project_dir = source_project_dir(path, subagent.is_some());
336    let created_at = rows
337        .iter()
338        .find_map(|row| parse_timestamp(&row.value).ok())
339        .ok_or_else(|| {
340            AdapterError::schema(NAME, display.clone(), "session has no parseable timestamp")
341        })?;
342    let raw_session_id = rows
343        .iter()
344        .find_map(|row| row.value.get("sessionId").and_then(Value::as_str))
345        .map(ToOwned::to_owned);
346
347    let (session_id, parent_session_id, source_agent, subagent_options, metadata_key) =
348        match subagent {
349            Some(SubagentDescriptor {
350                parent_uuid,
351                child_suffix,
352                agent_hash,
353                meta,
354                ..
355            }) => {
356                let child_id = format!("{parent_uuid}/{child_suffix}");
357                // Mirror claude_code's `options.subagent` shape so the sidecar
358                // restores losslessly; the metadata join keys on the parent's
359                // SDK uuid (the subagent shares its continuation).
360                let subagent_meta = json!({
361                    "hash": agent_hash,
362                    "raw_session_id": raw_session_id,
363                    "meta": meta,
364                });
365                let key = parent_uuid.clone();
366                (
367                    child_id,
368                    Some(parent_uuid),
369                    "nanoclaw/subagent".to_owned(),
370                    Some(subagent_meta),
371                    key,
372                )
373            }
374            None => {
375                let id = sdk_session_stem(path)
376                    .or_else(|| raw_session_id.clone())
377                    .ok_or_else(|| {
378                        AdapterError::schema(
379                            NAME,
380                            display.clone(),
381                            "cannot determine SDK session id from filename",
382                        )
383                    })?;
384                let key = id.clone();
385                (id, None, "nanoclaw".to_owned(), None, key)
386            }
387        };
388
389    let mut source = serde_json::Map::new();
390    source.insert("adapter".to_owned(), Value::String(NAME.to_owned()));
391    source.insert(
392        "agent_group_id".to_owned(),
393        Value::String(agent_group_id.clone()),
394    );
395    if let Some(dir) = &project_dir {
396        source.insert("project_dir".to_owned(), Value::String(dir.clone()));
397    }
398    let mut options = ProviderOptions::new();
399    options.insert("source".to_owned(), Value::Object(source));
400    if let Some(subagent_meta) = subagent_options {
401        options.insert("subagent".to_owned(), subagent_meta);
402    }
403    if let Some(nanoclaw) = metadata.get(metadata_key.as_str()) {
404        options.insert("nanoclaw".to_owned(), nanoclaw.clone());
405    }
406
407    let project = extract_self_str(&Value::String(agent_group_id)).ok_or_else(|| {
408        AdapterError::schema(
409            NAME,
410            display,
411            "internal: agent group id produced no project",
412        )
413    })?;
414
415    Ok(Session {
416        id: session_id,
417        parent_session_id,
418        parent_message_id: None,
419        source_agent,
420        created_at,
421        project,
422        options,
423    })
424}
425
426/// Native restore path from the install root: replays into the observed
427/// `data/v2-sessions/<agentGroupId>/.claude-shared/projects/<projectDir>/`
428/// layout, stored in `options.source` at ingest. Every id segment is validated.
429fn transcript_path(
430    session: &crate::sessions::SessionWithMessages,
431) -> Result<PathBuf, AdapterError> {
432    let source = session.session.options.get("source");
433    let group = source
434        .and_then(|source| source.get("agent_group_id"))
435        .and_then(Value::as_str)
436        .ok_or_else(|| {
437            AdapterError::schema(
438                NAME,
439                &session.session.id,
440                "session options.source.agent_group_id missing; cannot rebuild native path",
441            )
442        })?;
443    let project_dir = source
444        .and_then(|source| source.get("project_dir"))
445        .and_then(Value::as_str)
446        .ok_or_else(|| {
447            AdapterError::schema(
448                NAME,
449                &session.session.id,
450                "session options.source.project_dir missing; cannot rebuild native path",
451            )
452        })?;
453    validate_path_id(NAME, "agent_group_id", group, &session.session.id)?;
454    validate_path_id(NAME, "project_dir", project_dir, &session.session.id)?;
455
456    let mut path = PathBuf::from("data")
457        .join("v2-sessions")
458        .join(group)
459        .join(".claude-shared")
460        .join("projects")
461        .join(project_dir);
462    if let Some(parent) = &session.session.parent_session_id {
463        validate_path_id(NAME, "parent_session_id", parent, &session.session.id)?;
464        let child_suffix = session
465            .session
466            .id
467            .strip_prefix(&format!("{parent}/"))
468            .unwrap_or(&session.session.id);
469        for segment in child_suffix.split('/') {
470            validate_path_id(NAME, "child_suffix segment", segment, &session.session.id)?;
471        }
472        path = path
473            .join(parent)
474            .join("subagents")
475            .join(format!("{child_suffix}.jsonl"));
476    } else {
477        validate_path_id(NAME, "session_id", &session.session.id, &session.session.id)?;
478        path = path.join(format!("{}.jsonl", session.session.id));
479    }
480    Ok(path)
481}
482
483// --- Read-only SQLite metadata join (best-effort, degrades to absent) --------
484
485/// Every real IPC session folder under `data/v2-sessions`, as
486/// `(agentGroupId, sessionId, sessionPath)`, sorted for deterministic order.
487/// Skips non-dirs and dotfile entries (`.claude-shared` holds transcripts, not a
488/// session folder). The metadata index and provider enumeration walk this same
489/// tree, so they share one implementation.
490fn walk_session_dirs(root: &Path) -> Vec<(String, String, PathBuf)> {
491    let v2_sessions = root.join("data").join("v2-sessions");
492    let mut out = Vec::new();
493    let Ok(groups) = std::fs::read_dir(&v2_sessions) else {
494        return out;
495    };
496    let mut group_entries: Vec<_> = groups.flatten().collect();
497    group_entries.sort_by_key(std::fs::DirEntry::file_name);
498    for group in group_entries {
499        if !group.file_type().is_ok_and(|kind| kind.is_dir()) {
500            continue;
501        }
502        let Some(group_id) = group.file_name().to_str().map(ToOwned::to_owned) else {
503            continue;
504        };
505        let Ok(sessions) = std::fs::read_dir(group.path()) else {
506            continue;
507        };
508        let mut session_entries: Vec<_> = sessions.flatten().collect();
509        session_entries.sort_by_key(std::fs::DirEntry::file_name);
510        for session in session_entries {
511            if !session.file_type().is_ok_and(|kind| kind.is_dir()) {
512                continue;
513            }
514            let name = session.file_name();
515            let Some(session_id) = name.to_str() else {
516                continue;
517            };
518            if session_id.starts_with('.') {
519                continue;
520            }
521            out.push((group_id.clone(), session_id.to_owned(), session.path()));
522        }
523    }
524    out
525}
526
527/// Build `sdk_uuid -> options.nanoclaw` once, by scanning every
528/// `<group>/<sessionId>/outbound.db` for the transcript link, then joining each
529/// nanoclaw session against `data/v2.db`. Any unreadable DB or orphan is skipped;
530/// the result is only ever additive metadata.
531fn build_metadata(root: &Path) -> HashMap<String, Value> {
532    let mut map = HashMap::new();
533    let v2 = open_metadata_db(&root.join("data").join("v2.db"));
534    for (group_id, session_id, session_path) in walk_session_dirs(root) {
535        let outbound = session_path.join("outbound.db");
536        if !outbound.exists() {
537            continue;
538        }
539        let Some(sdk_uuid) = read_continuation(&outbound) else {
540            continue;
541        };
542        let nanoclaw = nanoclaw_metadata_object(v2.as_ref(), &session_id, &group_id);
543        map.insert(sdk_uuid, Value::Object(nanoclaw));
544    }
545    map
546}
547
548/// The `options.nanoclaw` object for one nanoclaw session: its id and agent group
549/// (always present, straight from the layout) plus the best-effort `v2.db` join
550/// against a shared connection (`None` when `v2.db` is unreadable). Shared by the
551/// claude metadata index and the opencode-provider composition.
552fn nanoclaw_metadata_object(
553    v2: Option<&Connection>,
554    session_id: &str,
555    group_id: &str,
556) -> serde_json::Map<String, Value> {
557    let mut nanoclaw = serde_json::Map::new();
558    nanoclaw.insert(
559        "nanoclaw_session_id".to_owned(),
560        Value::String(session_id.to_owned()),
561    );
562    nanoclaw.insert(
563        "agent_group_id".to_owned(),
564        Value::String(group_id.to_owned()),
565    );
566    if let Some(conn) = v2 {
567        for (key, value) in read_session_metadata(conn, session_id) {
568            nanoclaw.insert(key, value);
569        }
570    }
571    nanoclaw
572}
573
574fn is_malformed(message: &str) -> bool {
575    message.contains("malformed")
576}
577
578/// Open a metadata DB read-only (shared `sqlite::open_db`), retrying once on a
579/// Docker-Desktop page-cache `database disk image is malformed`. `None` on any
580/// other failure - metadata is best-effort and must never block ingestion. One
581/// open serves every session's join, not one per session.
582fn open_metadata_db(path: &Path) -> Option<Connection> {
583    for attempt in 0..2 {
584        match sqlite::open_db(NAME, path) {
585            Ok(conn) => return Some(conn),
586            Err(error) if attempt == 0 && is_malformed(&error.to_string()) => continue,
587            Err(_) => return None,
588        }
589    }
590    None
591}
592
593/// Open `path` read-only and run `query` once, retrying the open on a malformed
594/// page cache. Any failure degrades to `None`. For the per-folder `outbound.db`
595/// reads, where each DB is opened exactly once anyway.
596fn read_db<T>(path: &Path, query: impl Fn(&Connection) -> rusqlite::Result<T>) -> Option<T> {
597    let conn = open_metadata_db(path)?;
598    query(&conn).ok()
599}
600
601/// The SDK session uuid this container's transcript resumes from - the
602/// `continuation:claude` row (legacy key `sdk_session_id`).
603fn read_continuation(path: &Path) -> Option<String> {
604    read_db(path, |conn| {
605        let mut stmt = conn.prepare(
606            "SELECT key, value FROM session_state \
607             WHERE key IN ('continuation:claude', 'sdk_session_id')",
608        )?;
609        let rows = stmt.query_map([], |row| {
610            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
611        })?;
612        let mut primary = None;
613        let mut legacy = None;
614        for row in rows {
615            let (key, value) = row?;
616            if key == "continuation:claude" {
617                primary = Some(value);
618            } else {
619                legacy = Some(value);
620            }
621        }
622        Ok(primary.or(legacy))
623    })
624    .flatten()
625}
626
627/// Join one nanoclaw session against a shared `v2.db` connection. Prefers the
628/// full sessions/messaging_groups/agent_groups/container_configs join; on any
629/// error (a column absent on an old install, a corrupt DB) falls back to the
630/// minimal sessions-only projection, then to nothing. Only non-null columns are
631/// carried.
632fn read_session_metadata(conn: &Connection, session_id: &str) -> Vec<(String, Value)> {
633    join_full(conn, session_id)
634        .ok()
635        .flatten()
636        .or_else(|| join_minimal(conn, session_id).ok().flatten())
637        .unwrap_or_default()
638}
639
640fn str_column(row: &rusqlite::Row, index: usize) -> rusqlite::Result<Option<Value>> {
641    Ok(row.get::<_, Option<String>>(index)?.map(Value::String))
642}
643
644fn int_column(row: &rusqlite::Row, index: usize) -> rusqlite::Result<Option<Value>> {
645    Ok(row
646        .get::<_, Option<i64>>(index)?
647        .map(|number| Value::Number(number.into())))
648}
649
650fn present(pairs: Vec<(&str, Option<Value>)>) -> Vec<(String, Value)> {
651    pairs
652        .into_iter()
653        .filter_map(|(key, value)| value.map(|value| (key.to_owned(), value)))
654        .collect()
655}
656
657fn join_full(
658    conn: &Connection,
659    session_id: &str,
660) -> rusqlite::Result<Option<Vec<(String, Value)>>> {
661    let mut stmt = conn.prepare(
662        "SELECT s.thread_id, s.agent_provider, s.status, \
663                mg.channel_type, mg.platform_id, mg.name, mg.is_group, \
664                ag.name, ag.folder, \
665                cc.provider, cc.model, cc.assistant_name \
666         FROM sessions s \
667         LEFT JOIN messaging_groups mg ON mg.id = s.messaging_group_id \
668         LEFT JOIN agent_groups ag ON ag.id = s.agent_group_id \
669         LEFT JOIN container_configs cc ON cc.agent_group_id = s.agent_group_id \
670         WHERE s.id = ?1",
671    )?;
672    stmt.query_row([session_id], |row| {
673        Ok(present(vec![
674            ("thread_id", str_column(row, 0)?),
675            ("agent_provider", str_column(row, 1)?),
676            ("status", str_column(row, 2)?),
677            ("channel_type", str_column(row, 3)?),
678            ("platform_id", str_column(row, 4)?),
679            ("chat_name", str_column(row, 5)?),
680            ("is_group", int_column(row, 6)?),
681            ("agent_group_name", str_column(row, 7)?),
682            ("folder", str_column(row, 8)?),
683            ("provider", str_column(row, 9)?),
684            ("model", str_column(row, 10)?),
685            ("assistant_name", str_column(row, 11)?),
686        ]))
687    })
688    .optional()
689}
690
691fn join_minimal(
692    conn: &Connection,
693    session_id: &str,
694) -> rusqlite::Result<Option<Vec<(String, Value)>>> {
695    let mut stmt =
696        conn.prepare("SELECT thread_id, agent_provider, status FROM sessions WHERE id = ?1")?;
697    stmt.query_row([session_id], |row| {
698        Ok(present(vec![
699            ("thread_id", str_column(row, 0)?),
700            ("agent_provider", str_column(row, 1)?),
701            ("status", str_column(row, 2)?),
702        ]))
703    })
704    .optional()
705}
706
707// --- Providers: opencode composition + codex visible skips (plan 1.4, 3.4) -----
708
709/// User-facing reason a codex-provider session yields no transcript.
710const CODEX_SKIP_REASON: &str =
711    "codex provider keeps history server-side; pond ingests no transcript for it";
712
713/// The non-claude work enumerated from `data/v2-sessions` + `v2.db`: opencode
714/// stores to compose and codex sessions to skip visibly.
715struct ProviderWork {
716    opencode: Vec<OpencodeProviderSession>,
717    codex_skips: Vec<CodexSkip>,
718}
719
720/// One nanoclaw session whose opencode store the `opencode` reader composes, with
721/// the re-attribution inputs (the group project and the nanoclaw metadata object).
722struct OpencodeProviderSession {
723    data_dir: PathBuf,
724    project: Extracted<String>,
725    nanoclaw_options: Value,
726}
727
728struct CodexSkip {
729    session_id: String,
730    agent_group_id: String,
731}
732
733/// One session's resolved provider and its group, from the `v2.db` join.
734struct ResolvedSession {
735    agent_group_id: String,
736    provider: Option<String>,
737}
738
739/// Enumerate the opencode-provider stores (folder-driven: any session folder with
740/// an `opencode-xdg/`) and the codex sessions to skip (metadata-driven: resolved
741/// provider is `codex`). Both degrade to empty if `v2.db` is unreadable; opencode
742/// composition still runs from the filesystem alone.
743fn provider_work(root: &Path) -> ProviderWork {
744    let v2 = open_metadata_db(&root.join("data").join("v2.db"));
745    let resolved = resolve_providers(v2.as_ref());
746
747    let mut codex_skips: Vec<CodexSkip> = resolved
748        .iter()
749        .filter(|(_, info)| info.provider.as_deref() == Some("codex"))
750        .map(|(session_id, info)| CodexSkip {
751            session_id: session_id.clone(),
752            agent_group_id: info.agent_group_id.clone(),
753        })
754        .collect();
755    codex_skips.sort_by(|a, b| a.session_id.cmp(&b.session_id));
756
757    let mut opencode = Vec::new();
758    for (group_id, session_id, session_path) in walk_session_dirs(root) {
759        let Some(data_dir) = opencode_data_dir(&session_path) else {
760            continue;
761        };
762        let Some(project) = extract_self_str(&Value::String(group_id.clone())) else {
763            continue;
764        };
765        let nanoclaw_options = Value::Object(nanoclaw_metadata_object(
766            v2.as_ref(),
767            &session_id,
768            &group_id,
769        ));
770        opencode.push(OpencodeProviderSession {
771            data_dir,
772            project,
773            nanoclaw_options,
774        });
775    }
776
777    ProviderWork {
778        opencode,
779        codex_skips,
780    }
781}
782
783/// The opencode data dir inside a session's `opencode-xdg/` (OpenCode's XDG data
784/// dir): the `opencode/` app subdir when present, else the XDG root itself. `None`
785/// when there is no `opencode-xdg/` at all.
786fn opencode_data_dir(session_dir: &Path) -> Option<PathBuf> {
787    let xdg = session_dir.join("opencode-xdg");
788    if !xdg.is_dir() {
789        return None;
790    }
791    let nested = xdg.join("opencode");
792    Some(if nested.is_dir() { nested } else { xdg })
793}
794
795/// Resolve every session's provider from a shared `v2.db` connection:
796/// `sessions.agent_provider`, else the group's `container_configs.provider`
797/// (default `claude` is applied by the caller when both are null). Degrades to an
798/// empty map when `v2.db` is unreadable or the query errors.
799fn resolve_providers(v2: Option<&Connection>) -> HashMap<String, ResolvedSession> {
800    let Some(conn) = v2 else {
801        return HashMap::new();
802    };
803    let query = |conn: &Connection| -> rusqlite::Result<HashMap<String, ResolvedSession>> {
804        let mut stmt = conn.prepare(
805            "SELECT s.id, s.agent_group_id, s.agent_provider, cc.provider \
806             FROM sessions s \
807             LEFT JOIN container_configs cc ON cc.agent_group_id = s.agent_group_id",
808        )?;
809        let rows = stmt.query_map([], |row| {
810            let id: String = row.get(0)?;
811            let agent_group_id: String = row.get(1)?;
812            let session_provider: Option<String> = row.get(2)?;
813            let group_provider: Option<String> = row.get(3)?;
814            Ok((id, agent_group_id, session_provider.or(group_provider)))
815        })?;
816        let mut map = HashMap::new();
817        for row in rows {
818            let (id, agent_group_id, provider) = row?;
819            map.insert(
820                id,
821                ResolvedSession {
822                    agent_group_id,
823                    provider,
824                },
825            );
826        }
827        Ok(map)
828    };
829    query(conn).unwrap_or_default()
830}
831
832#[cfg(test)]
833mod tests {
834    //! Conformance tests for the nanoclaw adapter: fixture-driven claude-JSONL
835    //! mapping, queue-operation rule-3 carriers, subagent sidecar sessions, the
836    //! read-only metadata join (synthetic DBs from the real DDL), graceful DB
837    //! degradation, rotated-file ingestion, native restore, and nanoclaw's own
838    //! wiring of the shared claude-JSONL seams (opencode-xdg pruning, the
839    //! unrecognized-subagents refusal, the watermark walk-back).
840    #![allow(clippy::expect_used, clippy::unwrap_used)]
841
842    use super::*;
843    use crate::{handlers::ingest_adapter, sessions::Store, wire::Message};
844    use tempfile::TempDir;
845
846    const FIXTURE_ROOT: &str = concat!(
847        env!("CARGO_MANIFEST_DIR"),
848        "/tests/fixtures/adapter/nanoclaw"
849    );
850
851    const ANON_GROUP: &str = "agentgroup-anon-001";
852    const ANON_MAIN: &str = "cc6ea1c9-cab4-43e6-8fdf-7346aae26cbb";
853    const ANON_SUB_PARENT: &str = "a03cd144-8240-49e6-9c7d-4bb2f9c20f50";
854    const ANON_SUB_HASH: &str = "a02bb3e0917c9a078";
855
856    async fn ingest(root: &Path) -> anyhow::Result<(Store, TempDir)> {
857        let store_dir = TempDir::new()?;
858        let store = Store::open_local(store_dir.path()).await?;
859        let adapter = NanoclawAdapter::new(root);
860        ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
861        Ok((store, store_dir))
862    }
863
864    #[test]
865    fn is_rotated_and_stem_strip_the_epoch_suffix() {
866        assert!(is_rotated_transcript("abc.jsonl.rotated-1777000000000"));
867        assert!(!is_rotated_transcript("abc.jsonl"));
868        assert!(!is_rotated_transcript("abc.jsonl.rotated-notdigits"));
869        assert_eq!(
870            sdk_session_stem(Path::new("/x/abc.jsonl.rotated-1777000000000")),
871            Some("abc".to_owned()),
872        );
873        assert_eq!(
874            sdk_session_stem(Path::new("/x/abc.jsonl")),
875            Some("abc".to_owned())
876        );
877    }
878
879    /// `probe_default` returns the install root only when it holds
880    /// `data/v2-sessions/`; an empty candidate must not masquerade as a source.
881    /// (Bespoke, not `assert_probe_default`: that helper asserts the returned
882    /// path equals the created subpath, but nanoclaw returns the install root
883    /// while requiring the `data/v2-sessions/` subdir - the same reason
884    /// openclaw/opencode write bespoke probe tests.)
885    #[test]
886    fn probe_default_offers_a_root_that_holds_v2_sessions() -> anyhow::Result<()> {
887        let temp = TempDir::new()?;
888        let home = temp.path();
889        let env = Env::with_home(home);
890        assert!(NanoclawFactory.probe_default(&env).is_none());
891
892        let root = home.join("nanoclaw");
893        std::fs::create_dir_all(root.join("data").join("v2-sessions"))?;
894        let probe = NanoclawFactory.probe_default(&env);
895        let got = probe
896            .as_ref()
897            .and_then(|value| value.get("path"))
898            .and_then(Value::as_str);
899        assert_eq!(got, root.to_str(), "probe must offer the install root");
900        Ok(())
901    }
902
903    #[tokio::test(flavor = "multi_thread")]
904    async fn fixture_maps_claude_jsonl_with_nanoclaw_identity() -> anyhow::Result<()> {
905        let (store, _guard) = ingest(Path::new(FIXTURE_ROOT)).await?;
906
907        let main = store
908            .get_session(ANON_MAIN)
909            .await?
910            .expect("anon main session ingests");
911        assert_eq!(main.session.source_agent, "nanoclaw");
912        assert_eq!(
913            &*main.session.project, ANON_GROUP,
914            "project is the agent group"
915        );
916        assert_eq!(main.session.parent_session_id, None);
917        assert_eq!(
918            main.session
919                .options
920                .get("source")
921                .and_then(|s| s.get("adapter")),
922            Some(&Value::String("nanoclaw".to_owned())),
923        );
924        assert_eq!(
925            main.session
926                .options
927                .get("source")
928                .and_then(|s| s.get("agent_group_id"))
929                .and_then(Value::as_str),
930            Some(ANON_GROUP),
931        );
932        // No SQLite DBs in the fixtures -> metadata join is absent, transcript
933        // still ingests (the documented degradation).
934        assert!(
935            !main.session.options.contains_key("nanoclaw"),
936            "metadata absent without the join DBs",
937        );
938
939        // queue-operation records (no uuid/message) ride rule 3 as System carriers.
940        let saw_queue_carrier = main.messages.iter().any(|stored| {
941            matches!(&stored.message, Message::System { content, .. }
942                if content.as_deref().is_some_and(|c| c.contains("queue-operation")))
943        });
944        assert!(
945            saw_queue_carrier,
946            "queue-operation must survive as a System carrier"
947        );
948        Ok(())
949    }
950
951    #[tokio::test(flavor = "multi_thread")]
952    async fn subagent_sidecar_becomes_its_own_session() -> anyhow::Result<()> {
953        let (store, _guard) = ingest(Path::new(FIXTURE_ROOT)).await?;
954        let child_id = format!("{ANON_SUB_PARENT}/agent-{ANON_SUB_HASH}");
955        let child = store
956            .get_session(&child_id)
957            .await?
958            .expect("subagent sidecar surfaces under the derived child id");
959        assert_eq!(child.session.source_agent, "nanoclaw/subagent");
960        assert_eq!(
961            child.session.parent_session_id.as_deref(),
962            Some(ANON_SUB_PARENT)
963        );
964        assert_eq!(&*child.session.project, ANON_GROUP);
965        assert!(
966            child.session.options.contains_key("subagent"),
967            "subagent metadata must be stored for lossless restore",
968        );
969        Ok(())
970    }
971
972    /// Multi-subagent fan-out from the synthetic fixture: session B spawns 3.
973    #[tokio::test(flavor = "multi_thread")]
974    async fn synthetic_fan_out_yields_three_subagent_sessions() -> anyhow::Result<()> {
975        let (store, _guard) = ingest(Path::new(FIXTURE_ROOT)).await?;
976        let parent = "bebe90ad-7812-475f-b96c-bd7558d7d8d5";
977        for hash in [
978            "a0e5a13152ff2a47c",
979            "a30e47928e2cef281",
980            "aea3786d34dd9e61c",
981        ] {
982            let child_id = format!("{parent}/agent-{hash}");
983            let child = store
984                .get_session(&child_id)
985                .await?
986                .unwrap_or_else(|| panic!("subagent {child_id} must ingest"));
987            assert_eq!(child.session.parent_session_id.as_deref(), Some(parent));
988        }
989        Ok(())
990    }
991
992    fn write_transcript(root: &Path, group: &str, uuid: &str) -> PathBuf {
993        let dir = root
994            .join("data")
995            .join("v2-sessions")
996            .join(group)
997            .join(".claude-shared")
998            .join("projects")
999            .join("-workspace-agent");
1000        std::fs::create_dir_all(&dir).unwrap();
1001        let user = json!({
1002            "type": "user",
1003            "uuid": "u-1",
1004            "sessionId": uuid,
1005            "cwd": "/workspace/agent",
1006            "timestamp": "2026-04-27T19:30:03.003Z",
1007            "message": {"role": "user", "content": "hello nanoclaw"},
1008        });
1009        let assistant = json!({
1010            "type": "assistant",
1011            "uuid": "a-1",
1012            "sessionId": uuid,
1013            "cwd": "/workspace/agent",
1014            "timestamp": "2026-04-27T19:30:05.000Z",
1015            "message": {"role": "assistant", "content": [{"type": "text", "text": "hi"}]},
1016        });
1017        let path = dir.join(format!("{uuid}.jsonl"));
1018        std::fs::write(&path, format!("{user}\n{assistant}\n")).unwrap();
1019        path
1020    }
1021
1022    /// v2.db + outbound.db built from the real DDL: the SDK uuid links to the
1023    /// nanoclaw session, and the join surfaces channel/model in options.nanoclaw.
1024    #[tokio::test(flavor = "multi_thread")]
1025    async fn metadata_join_enriches_from_synthetic_dbs() -> anyhow::Result<()> {
1026        let root = TempDir::new()?;
1027        let group = "grp-A";
1028        let session_id = "sess-1777000000000-abcdef";
1029        let sdk = "11111111-1111-1111-1111-111111111111";
1030        write_transcript(root.path(), group, sdk);
1031
1032        // outbound.db: the transcript link.
1033        let sess_dir = root
1034            .path()
1035            .join("data")
1036            .join("v2-sessions")
1037            .join(group)
1038            .join(session_id);
1039        std::fs::create_dir_all(&sess_dir)?;
1040        let outbound = Connection::open(sess_dir.join("outbound.db"))?;
1041        outbound.execute_batch(
1042            "CREATE TABLE session_state (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL);",
1043        )?;
1044        outbound.execute(
1045            "INSERT INTO session_state (key, value, updated_at) VALUES ('continuation:claude', ?1, '2026-04-27T00:00:00Z')",
1046            [sdk],
1047        )?;
1048        drop(outbound);
1049
1050        // v2.db: the join tables (subset of the real schema).
1051        let v2 = Connection::open(root.path().join("data").join("v2.db"))?;
1052        v2.execute_batch(
1053            "CREATE TABLE agent_groups (id TEXT PRIMARY KEY, name TEXT NOT NULL, folder TEXT NOT NULL, agent_provider TEXT, created_at TEXT NOT NULL);
1054             CREATE TABLE messaging_groups (id TEXT PRIMARY KEY, channel_type TEXT NOT NULL, platform_id TEXT NOT NULL, instance TEXT NOT NULL, name TEXT, is_group INTEGER DEFAULT 0, created_at TEXT NOT NULL);
1055             CREATE TABLE container_configs (agent_group_id TEXT PRIMARY KEY, provider TEXT, model TEXT, assistant_name TEXT, updated_at TEXT NOT NULL);
1056             CREATE TABLE sessions (id TEXT PRIMARY KEY, agent_group_id TEXT NOT NULL, messaging_group_id TEXT, thread_id TEXT, agent_provider TEXT, status TEXT, created_at TEXT NOT NULL);
1057             INSERT INTO agent_groups VALUES ('ag-1', 'Founder Assistant', 'founder', 'claude', '2026-04-01T00:00:00Z');
1058             INSERT INTO messaging_groups VALUES ('mg-1', 'telegram', 'tg-123', 'telegram', 'Ops Channel', 1, '2026-04-01T00:00:00Z');
1059             INSERT INTO container_configs VALUES ('ag-1', 'claude', 'claude-opus-4', 'Nyx', '2026-04-01T00:00:00Z');
1060             INSERT INTO sessions VALUES ('sess-1777000000000-abcdef', 'ag-1', 'mg-1', 'thread-7', 'claude', 'active', '2026-04-27T00:00:00Z');",
1061        )?;
1062        drop(v2);
1063
1064        let (store, _guard) = ingest(root.path()).await?;
1065        let session = store.get_session(sdk).await?.expect("session ingests");
1066        let nanoclaw = session
1067            .session
1068            .options
1069            .get("nanoclaw")
1070            .expect("metadata join lands in options.nanoclaw");
1071        assert_eq!(
1072            nanoclaw.get("nanoclaw_session_id").and_then(Value::as_str),
1073            Some(session_id)
1074        );
1075        assert_eq!(
1076            nanoclaw.get("channel_type").and_then(Value::as_str),
1077            Some("telegram")
1078        );
1079        assert_eq!(
1080            nanoclaw.get("chat_name").and_then(Value::as_str),
1081            Some("Ops Channel")
1082        );
1083        assert_eq!(
1084            nanoclaw.get("model").and_then(Value::as_str),
1085            Some("claude-opus-4")
1086        );
1087        assert_eq!(
1088            nanoclaw.get("assistant_name").and_then(Value::as_str),
1089            Some("Nyx")
1090        );
1091        assert_eq!(
1092            nanoclaw.get("agent_group_name").and_then(Value::as_str),
1093            Some("Founder Assistant")
1094        );
1095        assert_eq!(nanoclaw.get("is_group").and_then(Value::as_i64), Some(1));
1096        Ok(())
1097    }
1098
1099    /// An unreadable outbound.db (or absent v2.db) must not block ingestion, and
1100    /// must never synthesize metadata: the transcript ingests with no join.
1101    #[tokio::test(flavor = "multi_thread")]
1102    async fn corrupt_db_degrades_to_metadata_absent() -> anyhow::Result<()> {
1103        let root = TempDir::new()?;
1104        let group = "grp-B";
1105        let sdk = "22222222-2222-2222-2222-222222222222";
1106        write_transcript(root.path(), group, sdk);
1107
1108        let sess_dir = root
1109            .path()
1110            .join("data")
1111            .join("v2-sessions")
1112            .join(group)
1113            .join("sess-broken");
1114        std::fs::create_dir_all(&sess_dir)?;
1115        std::fs::write(
1116            sess_dir.join("outbound.db"),
1117            b"this is not a sqlite database",
1118        )?;
1119
1120        let (store, _guard) = ingest(root.path()).await?;
1121        let session = store
1122            .get_session(sdk)
1123            .await?
1124            .expect("session still ingests");
1125        assert!(
1126            !session.session.options.contains_key("nanoclaw"),
1127            "an unreadable DB degrades to metadata-absent, never synthesized",
1128        );
1129        Ok(())
1130    }
1131
1132    /// A rotated `<uuid>.jsonl.rotated-<epochMs>` transcript (immutable, complete)
1133    /// is walked despite its non-`.jsonl` extension and ingests under the uuid.
1134    #[tokio::test(flavor = "multi_thread")]
1135    async fn rotated_transcript_is_ingested() -> anyhow::Result<()> {
1136        let root = TempDir::new()?;
1137        let dir = root
1138            .path()
1139            .join("data")
1140            .join("v2-sessions")
1141            .join("grp-C")
1142            .join(".claude-shared")
1143            .join("projects")
1144            .join("-workspace-agent");
1145        std::fs::create_dir_all(&dir)?;
1146        let uuid = "33333333-3333-3333-3333-333333333333";
1147        let row = json!({
1148            "type": "user",
1149            "uuid": "u-rot",
1150            "sessionId": uuid,
1151            "cwd": "/workspace/agent",
1152            "timestamp": "2026-04-27T19:30:03.003Z",
1153            "message": {"role": "user", "content": "rotated"},
1154        });
1155        std::fs::write(
1156            dir.join(format!("{uuid}.jsonl.rotated-1777000000000")),
1157            format!("{row}\n"),
1158        )?;
1159
1160        let (store, _guard) = ingest(root.path()).await?;
1161        let session = store
1162            .get_session(uuid)
1163            .await?
1164            .expect("rotated transcript ingests under its uuid");
1165        assert_eq!(session.session.source_agent, "nanoclaw");
1166        assert!(!session.messages.is_empty());
1167        Ok(())
1168    }
1169
1170    /// The freshness gate: an empty oracle marks everything pending (walk cost
1171    /// only); a saturated oracle marks every readable-id session fresh. Exercises
1172    /// `peek_session_id` (filename stem + subagent child id) and `peek_watermark`.
1173    #[tokio::test(flavor = "multi_thread")]
1174    async fn plan_classifies_fresh_vs_pending() -> anyhow::Result<()> {
1175        use crate::adapter::test_support::MaxWatermarkOracle;
1176
1177        let adapter = NanoclawAdapter::new(FIXTURE_ROOT);
1178        let first = adapter
1179            .plan(&crate::adapter::NoopOracle)
1180            .await?
1181            .expect("jsonl-tree adapters support plan");
1182        assert!(first.sessions > 0);
1183        assert_eq!(first.pending, first.sessions);
1184        assert_eq!(first.fresh, 0);
1185
1186        let caught_up = adapter
1187            .plan(&MaxWatermarkOracle)
1188            .await?
1189            .expect("jsonl-tree adapters support plan");
1190        assert_eq!(caught_up.sessions, first.sessions);
1191        assert!(caught_up.fresh > 0, "fixture sessions must gate as fresh");
1192        assert_eq!(caught_up.fresh + caught_up.pending, caught_up.sessions);
1193        Ok(())
1194    }
1195
1196    /// Native restore round-trips a constructed source tree (main + subagent +
1197    /// a valid `.meta.json`). A minimal tree is built rather than reusing the
1198    /// committed fixtures: `assert_native_restore` requires the restored set to
1199    /// equal ALL json/jsonl under the root, and the fixtures' empty `.meta.json`
1200    /// sidecars are not valid JSON and are not reproduced by serialize.
1201    #[tokio::test(flavor = "multi_thread")]
1202    async fn native_restore_round_trips_constructed_tree() -> anyhow::Result<()> {
1203        let source = TempDir::new()?;
1204        let group = "grp-R";
1205        let main_uuid = "44444444-4444-4444-4444-444444444444";
1206        let projects = source
1207            .path()
1208            .join("data")
1209            .join("v2-sessions")
1210            .join(group)
1211            .join(".claude-shared")
1212            .join("projects")
1213            .join("-workspace-agent");
1214        std::fs::create_dir_all(&projects)?;
1215
1216        let main_user = json!({
1217            "type": "user",
1218            "uuid": "u-main",
1219            "sessionId": main_uuid,
1220            "cwd": "/workspace/agent",
1221            "timestamp": "2026-04-27T19:30:03.003Z",
1222            "message": {"role": "user", "content": "restore me"},
1223        });
1224        let main_assistant = json!({
1225            "type": "assistant",
1226            "uuid": "a-main",
1227            "sessionId": main_uuid,
1228            "cwd": "/workspace/agent",
1229            "timestamp": "2026-04-27T19:30:05.000Z",
1230            "message": {"role": "assistant", "content": [{"type": "text", "text": "done"}]},
1231        });
1232        std::fs::write(
1233            projects.join(format!("{main_uuid}.jsonl")),
1234            format!("{main_user}\n{main_assistant}\n"),
1235        )?;
1236
1237        let sub_hash = "a0deadbeef00cafe1";
1238        let sub_dir = projects.join(main_uuid).join("subagents");
1239        std::fs::create_dir_all(&sub_dir)?;
1240        let sub_row = json!({
1241            "type": "user",
1242            "uuid": "u-sub",
1243            "sessionId": main_uuid,
1244            "cwd": "/workspace/agent",
1245            "isSidechain": true,
1246            "timestamp": "2026-04-27T19:31:00.000Z",
1247            "message": {"role": "user", "content": "subagent prompt"},
1248        });
1249        std::fs::write(
1250            sub_dir.join(format!("agent-{sub_hash}.jsonl")),
1251            format!("{sub_row}\n"),
1252        )?;
1253        // A VALID meta.json object so serialize reproduces it (empty is invalid).
1254        std::fs::write(
1255            sub_dir.join(format!("agent-{sub_hash}.meta.json")),
1256            r#"{"agentType":"researcher","description":"dig"}"#,
1257        )?;
1258
1259        let adapter = NanoclawAdapter::new(source.path());
1260        crate::adapter::test_support::assert_native_restore(
1261            &NanoclawFactory,
1262            &adapter,
1263            source.path(),
1264        )
1265        .await
1266    }
1267
1268    /// A decoy claude-style `.jsonl` planted under `opencode-xdg/` must never
1269    /// ingest through the plain JSONL walk: `skip_source` prunes the whole
1270    /// subtree, and that store is reachable only via the composed opencode
1271    /// reader. Pre-guard, the decoy row is a fully valid transcript (it would
1272    /// ingest as its own session), so this pins the pruning predicate rather
1273    /// than relying on real stores happening to hold no `.jsonl`.
1274    #[tokio::test(flavor = "multi_thread")]
1275    async fn opencode_xdg_subtree_is_pruned_from_the_plain_walk() -> anyhow::Result<()> {
1276        let root = TempDir::new()?;
1277        let group = "grp-P";
1278        let real_uuid = "55555555-5555-5555-5555-555555555555";
1279        write_transcript(root.path(), group, real_uuid);
1280
1281        let decoy_uuid = "66666666-6666-6666-6666-666666666666";
1282        let decoy_dir = root
1283            .path()
1284            .join("data")
1285            .join("v2-sessions")
1286            .join(group)
1287            .join("sess-1777000000000-decoy")
1288            .join("opencode-xdg")
1289            .join("storage")
1290            .join("session");
1291        std::fs::create_dir_all(&decoy_dir)?;
1292        let decoy_row = json!({
1293            "type": "user",
1294            "uuid": "u-decoy",
1295            "sessionId": decoy_uuid,
1296            "cwd": "/workspace/agent",
1297            "timestamp": "2026-04-27T19:40:00.000Z",
1298            "message": {"role": "user", "content": "must never ingest via the walk"},
1299        });
1300        std::fs::write(decoy_dir.join("decoy.jsonl"), format!("{decoy_row}\n"))?;
1301
1302        let adapter = NanoclawAdapter::new(root.path());
1303        assert_eq!(
1304            adapter.discover().await?,
1305            1,
1306            "discovery must see only the real transcript - opencode-xdg is pruned",
1307        );
1308
1309        let (store, _guard) = ingest(root.path()).await?;
1310        assert!(
1311            store.get_session(real_uuid).await?.is_some(),
1312            "the real transcript still ingests"
1313        );
1314        assert!(
1315            store.get_session(decoy_uuid).await?.is_none(),
1316            "nothing under opencode-xdg/ may ingest through the plain walk",
1317        );
1318        Ok(())
1319    }
1320
1321    /// nanoclaw's wiring of the shared unrecognized-`subagents/`-leaf refusal
1322    /// (`unsupported_reason` + the `session_from_rows` guard): a `.jsonl` under
1323    /// `subagents/` whose leaf is not `agent-<hash>.jsonl` must surface as a
1324    /// visible, counted skip and must NOT merge into the parent via its
1325    /// borrowed content `sessionId`. Mirrors claude_code's
1326    /// `unrecognized_subagents_file_fails_visibly_not_merged` through nanoclaw's
1327    /// own hooks.
1328    #[tokio::test(flavor = "multi_thread")]
1329    async fn unrecognized_subagents_leaf_skips_visibly_not_merged() -> anyhow::Result<()> {
1330        let root = TempDir::new()?;
1331        let group = "grp-U";
1332        let parent_uuid = "77777777-7777-7777-7777-777777777777";
1333        let parent_path = write_transcript(root.path(), group, parent_uuid);
1334
1335        // Same parent sessionId AND same cwd: pre-guard this would have merged
1336        // silently into the parent. The leaf name is not `agent-<hash>.jsonl`.
1337        let unknown_dir = parent_path
1338            .parent()
1339            .expect("transcript has a project dir")
1340            .join(parent_uuid)
1341            .join("subagents")
1342            .join("workflows")
1343            .join("wf_future01-aaa");
1344        std::fs::create_dir_all(&unknown_dir)?;
1345        let unknown_row = json!({
1346            "type": "user",
1347            "uuid": "u-should-not-merge",
1348            "sessionId": parent_uuid,
1349            "cwd": "/workspace/agent",
1350            "timestamp": "2026-04-27T19:45:00.000Z",
1351            "message": {"role": "user", "content": "must not land under parent"},
1352        });
1353        std::fs::write(
1354            unknown_dir.join("transcript-001.jsonl"),
1355            format!("{unknown_row}\n"),
1356        )?;
1357
1358        let store_dir = TempDir::new()?;
1359        let store = Store::open_local(store_dir.path()).await?;
1360        let adapter = NanoclawAdapter::new(root.path());
1361        let summary = ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
1362
1363        assert_eq!(
1364            summary.skipped_files, 1,
1365            "the unrecognized subagents/ transcript must be a visible, counted skip",
1366        );
1367        let parent = store
1368            .get_session(parent_uuid)
1369            .await?
1370            .expect("parent session ingests");
1371        assert_eq!(
1372            parent.messages.len(),
1373            2,
1374            "parent keeps only its own two rows - nothing merged in",
1375        );
1376        assert!(
1377            parent
1378                .messages
1379                .iter()
1380                .all(|m| m.message.id() != "u-should-not-merge"),
1381            "parent must not absorb the unrecognized file's message",
1382        );
1383        Ok(())
1384    }
1385
1386    /// nanoclaw's `peek_watermark` wiring of the shared walk-back: trailing
1387    /// untimestamped metadata rows after the conversation must not hide the
1388    /// last real message's timestamp. Exercised on a rotated transcript so the
1389    /// walk-back and the non-`.jsonl` acceptance are pinned together through
1390    /// nanoclaw's own hooks.
1391    #[test]
1392    fn peek_watermark_walks_back_past_trailing_rows_on_rotated_files() {
1393        let root = TempDir::new().unwrap();
1394        let dir = root
1395            .path()
1396            .join("data")
1397            .join("v2-sessions")
1398            .join("grp-W")
1399            .join(".claude-shared")
1400            .join("projects")
1401            .join("-workspace-agent");
1402        std::fs::create_dir_all(&dir).unwrap();
1403        let uuid = "88888888-8888-8888-8888-888888888888";
1404        let message = json!({
1405            "type": "user",
1406            "uuid": "u-wm",
1407            "sessionId": uuid,
1408            "cwd": "/workspace/agent",
1409            "timestamp": "2026-04-27T19:50:00.000Z",
1410            "message": {"role": "user", "content": "hello"},
1411        });
1412        // Metadata rows Claude Code writes after the conversation - no timestamp.
1413        let last_prompt = json!({"type": "last-prompt", "sessionId": uuid, "prompt": "hi"});
1414        let permission = json!({"type": "permission-mode", "sessionId": uuid});
1415        let path = dir.join(format!("{uuid}.jsonl.rotated-1777000000000"));
1416        std::fs::write(&path, format!("{message}\n{last_prompt}\n{permission}\n")).unwrap();
1417
1418        let adapter = NanoclawAdapter::new(root.path());
1419        let expected = chrono::DateTime::parse_from_rfc3339("2026-04-27T19:50:00.000Z")
1420            .unwrap()
1421            .timestamp_micros();
1422        assert_eq!(
1423            adapter.peek_watermark(&path),
1424            SourceWatermark::At(expected),
1425            "walk back past trailing metadata to the last message's timestamp",
1426        );
1427    }
1428}