1use 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
92const PROBE_CANDIDATES: &[&[&str]] = &[&["nanoclaw"]];
96
97pub 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 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#[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 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
271fn 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
284fn 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
300fn 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 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 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
426fn 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
483fn 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
527fn 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
548fn 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
578fn 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
593fn 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
601fn 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
627fn 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
707const CODEX_SKIP_REASON: &str =
711 "codex provider keeps history server-side; pond ingests no transcript for it";
712
713struct ProviderWork {
716 opencode: Vec<OpencodeProviderSession>,
717 codex_skips: Vec<CodexSkip>,
718}
719
720struct 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
733struct ResolvedSession {
735 agent_group_id: String,
736 provider: Option<String>,
737}
738
739fn 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
783fn 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
795fn 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 #![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 #[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 assert!(
935 !main.session.options.contains_key("nanoclaw"),
936 "metadata absent without the join DBs",
937 );
938
939 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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 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}