1use crate::runtime::memory_context::UserIntentProof;
11use std::path::PathBuf;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct HistorySessionMetadata {
17 pub id: String,
18 pub approx_bytes: u64,
19 pub started_at: String,
21}
22
23pub trait HistoryImportIo {
26 fn scan_metadata(&mut self) -> Result<Vec<HistorySessionMetadata>, HistoryImportError>;
27 fn read_session_content(&mut self, id: &str) -> Result<(), HistoryImportError>;
28 fn write_axel(&mut self) -> Result<(), HistoryImportError>;
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct HistoryImportHostState {
35 pub project_id: String,
36 pub project_root: PathBuf,
37 pub destination_r8_path: PathBuf,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct IncludedDateRange {
42 pub earliest: String,
43 pub latest: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct HistoryImportPreview {
49 pub project_id: String,
50 pub project_root: PathBuf,
51 pub session_count: usize,
52 pub approx_bytes: u64,
53 pub included_date_range: Option<IncludedDateRange>,
54 pub included_content_classes: Vec<&'static str>,
55 pub excluded_content_classes: Vec<&'static str>,
56 pub retention_policy: String,
57 pub redaction_policy: String,
58 pub destination_r8_path: PathBuf,
59 pub explicit_confirmation_required: bool,
60}
61
62impl HistoryImportPreview {
63 pub fn render(&self) -> String {
65 let date_range = self
66 .included_date_range
67 .as_ref()
68 .map(|range| format!("{} to {}", range.earliest, range.latest))
69 .unwrap_or_else(|| "none".to_string());
70 format!(
71 "History import preview\n\
72 project: {}\n\
73 root: {}\n\
74 sessions: {}\n\
75 approximate bytes: {}\n\
76 included date range: {}\n\
77 included classes: {}\n\
78 excluded classes: {}\n\
79 retention policy: {}\n\
80 redaction policy: {}\n\
81 destination .r8: {}\n\
82 explicit confirmation required: {}\n\
83 Run `/memory index-history confirm` to create an import plan; no import has started.",
84 self.project_id,
85 self.project_root.display(),
86 self.session_count,
87 self.approx_bytes,
88 date_range,
89 self.included_content_classes.join(", "),
90 self.excluded_content_classes.join(", "),
91 self.retention_policy,
92 self.redaction_policy,
93 self.destination_r8_path.display(),
94 self.explicit_confirmation_required,
95 )
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct ConfirmedUserIntent {
103 proof: UserIntentProof,
104}
105
106impl ConfirmedUserIntent {
107 pub(crate) fn from_explicit_command(
111 command_id: crate::runtime::memory_context::RequestId,
112 ) -> Self {
113 Self {
114 proof: UserIntentProof::ExplicitCommand { command_id },
115 }
116 }
117
118 #[cfg(test)]
120 pub(crate) fn from_confirmed_prompt(
121 confirmation_id: crate::runtime::memory_context::ConfirmationId,
122 ) -> Self {
123 Self {
124 proof: UserIntentProof::ConfirmedPrompt { confirmation_id },
125 }
126 }
127
128 fn confirmation_id(&self) -> &str {
129 match &self.proof {
130 UserIntentProof::ExplicitCommand { command_id } => command_id.as_str(),
131 UserIntentProof::ConfirmedPrompt { confirmation_id } => confirmation_id.as_str(),
132 UserIntentProof::ExactCurrentRequest { .. } => "exact-current-request",
133 }
134 }
135
136 #[cfg(test)]
137 fn for_test(confirmation_id: &str) -> Self {
138 use crate::runtime::memory_context::ConfirmationId;
139 Self::from_confirmed_prompt(
140 ConfirmationId::parse(confirmation_id).expect("test confirmation id is valid"),
141 )
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum HistoryImportConsent {
147 Declined,
148 Confirmed(ConfirmedUserIntent),
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum RequestAuthority {
154 UserFrontend,
155 ModelTool,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct ImportPlan {
161 pub preview: HistoryImportPreview,
162 pub confirmation_id: String,
163 pub user_intent: UserIntentProof,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
167#[allow(clippy::large_enum_variant)] pub enum HistoryImportOutcome {
169 Declined,
170 Ready(ImportPlan),
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
174pub enum HistoryImportError {
175 #[error("a model tool call may propose history import but cannot confirm it")]
176 ModelCannotConfirm,
177 #[error("history import requires explicit user confirmation after preview")]
178 ConsentRequired,
179 #[error("history metadata scan failed")]
180 MetadataScanFailed,
181 #[error("history import host scope is unavailable")]
182 HostStateUnavailable,
183 #[error("history import batch size is outside the host bound")]
184 InvalidBatchSize,
185 #[error("history import session index could not be read")]
186 SessionIndexReadFailed,
187 #[error("history import session could not be loaded")]
188 SessionLoadFailed,
189 #[error("history import identity is invalid")]
190 InvalidIdentity,
191 #[error("history import capture could not be built")]
192 CaptureBuildFailed,
193 #[error("history import requires a live capture lease")]
194 CaptureLeaseUnavailable,
195 #[error("history import requires the leased local provider")]
196 CaptureProviderUnavailable,
197 #[error("history import capture queue is full")]
198 CaptureQueueFull,
199 #[error("history import was cancelled")]
200 Cancelled,
201 #[error("history import checkpoint could not be read")]
202 CheckpointReadFailed,
203 #[error("history import checkpoint could not be committed")]
204 CheckpointWriteFailed,
205 #[error("history import provider capture failed")]
206 ProviderCaptureFailed,
207}
208
209pub const IMPORT_BATCH_MAX_RECORDS: usize = 32;
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
215pub struct HistoryImportReport {
216 pub sessions_loaded: usize,
217 pub captures_built: usize,
218 pub batches_submitted: usize,
219 pub ranges_skipped: usize,
220}
221
222pub const CHECKPOINT_FILE_MAX_BYTES: u64 = 256 * 1024;
225
226#[derive(Debug, Clone, Default)]
227pub struct HistoryImportCancellation(std::sync::Arc<std::sync::atomic::AtomicBool>);
228
229impl HistoryImportCancellation {
230 pub fn new() -> Self {
231 Self::default()
232 }
233
234 pub fn cancel(&self) {
235 self.0.store(true, std::sync::atomic::Ordering::Release);
236 }
237
238 fn is_cancelled(&self) -> bool {
239 self.0.load(std::sync::atomic::Ordering::Acquire)
240 }
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
246pub struct HistoryImportEvent {
247 pub name: &'static str,
248 pub status: &'static str,
249 pub ranges_committed: usize,
250 pub ranges_total: usize,
251 pub bytes_processed: u64,
252 pub duration_ms: u64,
253 #[serde(skip_serializing_if = "Option::is_none")]
254 pub error_code: Option<&'static str>,
255}
256
257impl HistoryImportEvent {
258 pub fn progress(
259 ranges_committed: usize,
260 ranges_total: usize,
261 bytes_processed: u64,
262 duration: std::time::Duration,
263 ) -> Self {
264 Self {
265 name: "memory_import.progress",
266 status: "running",
267 ranges_committed,
268 ranges_total,
269 bytes_processed,
270 duration_ms: duration.as_millis().min(u128::from(u64::MAX)) as u64,
271 error_code: None,
272 }
273 }
274
275 pub fn error(
276 ranges_committed: usize,
277 bytes_processed: u64,
278 duration: std::time::Duration,
279 error: HistoryImportError,
280 ) -> Self {
281 Self {
282 name: "memory_import.progress",
283 status: "error",
284 ranges_committed,
285 ranges_total: 0,
286 bytes_processed,
287 duration_ms: duration.as_millis().min(u128::from(u64::MAX)) as u64,
288 error_code: Some(error.code()),
289 }
290 }
291}
292
293impl HistoryImportError {
294 fn code(&self) -> &'static str {
295 match self {
296 Self::ModelCannotConfirm => "model_cannot_confirm",
297 Self::ConsentRequired => "consent_required",
298 Self::MetadataScanFailed => "metadata_scan_failed",
299 Self::HostStateUnavailable => "host_state_unavailable",
300 Self::InvalidBatchSize => "invalid_batch_size",
301 Self::SessionIndexReadFailed => "session_index_read_failed",
302 Self::SessionLoadFailed => "session_load_failed",
303 Self::InvalidIdentity => "invalid_identity",
304 Self::CaptureBuildFailed => "capture_build_failed",
305 Self::CaptureLeaseUnavailable => "capture_lease_unavailable",
306 Self::CaptureProviderUnavailable => "capture_provider_unavailable",
307 Self::CaptureQueueFull => "capture_queue_full",
308 Self::Cancelled => "cancelled",
309 Self::CheckpointReadFailed => "checkpoint_read_failed",
310 Self::CheckpointWriteFailed => "checkpoint_write_failed",
311 Self::ProviderCaptureFailed => "provider_capture_failed",
312 }
313 }
314}
315
316#[derive(Debug, serde::Serialize, serde::Deserialize)]
317struct ImportCheckpoint {
318 version: u8,
319 key: String,
320 committed_source_digests: Vec<String>,
321}
322
323#[cfg(any(test, feature = "testing"))]
324pub fn authorize_import_for_tests(
325 preview: HistoryImportPreview,
326) -> (
327 ImportPlan,
328 crate::runtime::memory_context::MemoryContextLease,
329) {
330 use crate::runtime::memory_context::{
331 mint_explicit_command_proof, CapturePolicy, ContextProviderId, MemoryContextLease,
332 MemoryContextMode, MemoryLeaseId, ProjectId, RecallPolicy, SessionId,
333 };
334
335 let intent = mint_explicit_command_proof();
336 let plan = ImportPlan {
337 preview: preview.clone(),
338 confirmation_id: "history-import-test-confirmation".into(),
339 user_intent: intent.clone(),
340 };
341 let lease = MemoryContextLease::grant(
342 MemoryLeaseId::parse("history-import-test-lease").expect("static lease id"),
343 SessionId::parse("history-import-test-session").expect("static session id"),
344 ProjectId::parse(&preview.project_id).expect("test project id must be valid"),
345 ContextProviderId::parse("extension:test:memory").expect("static provider id"),
346 MemoryContextMode::CaptureOnly,
347 CapturePolicy::default(),
348 RecallPolicy::default(),
349 intent,
350 std::time::SystemTime::now(),
351 None,
352 )
353 .expect("test lease must be valid");
354 (plan, lease)
355}
356
357pub(crate) fn import_history_from_dir(
363 plan: &ImportPlan,
364 lease: &crate::runtime::memory_context::MemoryContextLease,
365 sessions_dir: &std::path::Path,
366 batch_size: usize,
367 worker: &crate::runtime::capture_worker::CaptureWorker,
368 provider: std::sync::Arc<dyn crate::runtime::capture_worker::CaptureProvider>,
369) -> Result<HistoryImportReport, HistoryImportError> {
370 use crate::runtime::chat_capture::{
371 build_chat_turn_capture, CanonicalCaptureItem, CaptureContentClass, Sensitivity,
372 TerminalTurnHistory,
373 };
374 use crate::runtime::memory_context::{ProjectId, RetentionClass, SessionId, TurnId};
375 use agent_core::core::disclosure::{
376 gate_for_model, may_persist, DisclosureClass, ModelVisibility,
377 };
378 use agent_core::core::session_index::{SessionIndexEventKind, SessionIndexRecord};
379 use std::collections::HashSet;
380 use std::io::BufRead;
381 use std::time::{Duration, SystemTime};
382
383 if batch_size == 0 || batch_size > IMPORT_BATCH_MAX_RECORDS {
384 return Err(HistoryImportError::InvalidBatchSize);
385 }
386 if lease.project_id.as_str() != plan.preview.project_id {
387 return Err(HistoryImportError::InvalidIdentity);
388 }
389
390 let index = match std::fs::File::open(sessions_dir.join("index.jsonl")) {
393 Ok(index) => index,
394 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
395 return Ok(HistoryImportReport::default())
396 }
397 Err(_) => return Err(HistoryImportError::SessionIndexReadFailed),
398 };
399 let mut scoped = HashSet::new();
400 for line in std::io::BufReader::new(index).lines() {
401 let line = line.map_err(|_| HistoryImportError::SessionIndexReadFailed)?;
402 let Ok(record) = serde_json::from_str::<SessionIndexRecord>(&line) else {
403 continue;
404 };
405 if record.event == SessionIndexEventKind::Start
406 && record.cwd.as_deref() == Some(plan.preview.project_root.as_path())
407 {
408 scoped.insert(record.session_id);
409 }
410 }
411
412 let project_id = ProjectId::parse(&plan.preview.project_id)
413 .map_err(|_| HistoryImportError::InvalidIdentity)?;
414 let redactor = |text: &str| redact_import_text(text);
415 let mut captures = Vec::with_capacity(batch_size);
416 let mut report = HistoryImportReport::default();
417
418 let mut ids: Vec<String> = agent_core::session_journal::session_dir_entries(sessions_dir)
421 .map_err(|_| HistoryImportError::SessionIndexReadFailed)?
422 .into_iter()
423 .filter_map(|entry| entry.name.strip_suffix(".json").map(str::to_owned))
424 .filter(|id| scoped.contains(id))
425 .collect();
426 ids.sort();
427
428 for id in ids {
429 let session = agent_core::session::Session::load_from_dir(sessions_dir, &id)
430 .map_err(|_| HistoryImportError::SessionLoadFailed)?;
431 report.sessions_loaded += 1;
432 let session_id =
433 SessionId::parse(&session.id).map_err(|_| HistoryImportError::InvalidIdentity)?;
434 let started_at: SystemTime = session.created_at.into();
435 let completed_at: SystemTime = session.updated_at.into();
436
437 let mut pending_user: Option<String> = None;
438 let mut ordinal = 0_u64;
439 for message in &session.api_messages {
440 let role = message.get("role").and_then(serde_json::Value::as_str);
441 let Some(text) = import_message_text(message) else {
442 continue;
443 };
444 match role {
445 Some("user") => pending_user = Some(text),
446 Some("assistant") => {
447 let Some(user) = pending_user.take() else {
448 continue;
449 };
450 ordinal = ordinal.saturating_add(1);
451 let disclosure = DisclosureClass::ModelVisibleAfterRedaction;
452 if !may_persist(disclosure) {
453 continue;
454 }
455 let user = match gate_for_model(disclosure, &user, false, Some(&redactor)) {
456 ModelVisibility::Visible(text) => text,
457 ModelVisibility::Withheld(_) => continue,
458 };
459 let assistant = match gate_for_model(disclosure, &text, false, Some(&redactor))
460 {
461 ModelVisibility::Visible(text) => text,
462 ModelVisibility::Withheld(_) => continue,
463 };
464 let history = TerminalTurnHistory {
465 project_id: project_id.clone(),
466 session_id: session_id.clone(),
467 turn_id: TurnId::parse(&format!("import-{ordinal}"))
468 .map_err(|_| HistoryImportError::InvalidIdentity)?,
469 turn_ordinal: ordinal,
470 started_at,
471 completed_at: completed_at.max(started_at + Duration::from_nanos(1)),
472 outcome: agent_core::TurnOutcome::Completed,
473 items: vec![
474 CanonicalCaptureItem {
475 project_id: project_id.clone(),
476 class: CaptureContentClass::UserMessage,
477 disclosure,
478 sensitivity: Sensitivity::Normal,
479 text: user,
480 tool_name: None,
481 },
482 CanonicalCaptureItem {
483 project_id: project_id.clone(),
484 class: CaptureContentClass::AssistantFinal,
485 disclosure,
486 sensitivity: Sensitivity::Normal,
487 text: assistant,
488 tool_name: None,
489 },
490 ],
491 compaction: None,
492 };
493 captures.push(
494 build_chat_turn_capture(&project_id, history, RetentionClass::Standard)
495 .map_err(|_| HistoryImportError::CaptureBuildFailed)?,
496 );
497 report.captures_built += 1;
498 if captures.len() == batch_size {
499 submit_import_batch(worker, lease, provider.clone(), &mut captures)?;
500 report.batches_submitted += 1;
501 }
502 }
503 _ => {} }
505 }
506 }
507 if !captures.is_empty() {
508 submit_import_batch(worker, lease, provider, &mut captures)?;
509 report.batches_submitted += 1;
510 }
511 Ok(report)
512}
513
514#[allow(clippy::too_many_arguments)] pub fn import_history_resumable_from_dir(
520 plan: &ImportPlan,
521 lease: &crate::runtime::memory_context::MemoryContextLease,
522 sessions_dir: &std::path::Path,
523 checkpoint_path: &std::path::Path,
524 batch_size: usize,
525 worker: &crate::runtime::capture_worker::CaptureWorker,
526 provider: std::sync::Arc<dyn crate::runtime::capture_worker::CaptureProvider>,
527 cancellation: &HistoryImportCancellation,
528 emit: &mut impl FnMut(HistoryImportEvent),
529) -> Result<HistoryImportReport, HistoryImportError> {
530 use crate::runtime::chat_capture::{
531 build_chat_turn_capture, CanonicalCaptureItem, CaptureContentClass, Sensitivity,
532 TerminalTurnHistory,
533 };
534 use crate::runtime::memory_context::{ProjectId, RetentionClass, SessionId, TurnId};
535 use agent_core::core::disclosure::{
536 gate_for_model, may_persist, DisclosureClass, ModelVisibility,
537 };
538 use agent_core::core::session_index::{SessionIndexEventKind, SessionIndexRecord};
539 use std::collections::HashSet;
540 use std::io::BufRead;
541 use std::sync::mpsc;
542 use std::time::{Duration, Instant, SystemTime};
543
544 let started = Instant::now();
545 let mut report = HistoryImportReport::default();
546 let mut bytes_processed = 0_u64;
547 let key = import_checkpoint_key(&plan.preview.project_id);
548 let mut committed = load_import_checkpoint(checkpoint_path, &key)?;
549
550 let run = (|| {
551 if batch_size == 0 || batch_size > IMPORT_BATCH_MAX_RECORDS {
552 return Err(HistoryImportError::InvalidBatchSize);
553 }
554 if lease.project_id.as_str() != plan.preview.project_id {
555 return Err(HistoryImportError::InvalidIdentity);
556 }
557 if cancellation.is_cancelled() {
558 return Err(HistoryImportError::Cancelled);
559 }
560
561 let index = match std::fs::File::open(sessions_dir.join("index.jsonl")) {
562 Ok(index) => index,
563 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(report),
564 Err(_) => return Err(HistoryImportError::SessionIndexReadFailed),
565 };
566 let mut scoped = HashSet::new();
567 for line in std::io::BufReader::new(index).lines() {
568 let line = line.map_err(|_| HistoryImportError::SessionIndexReadFailed)?;
569 let Ok(record) = serde_json::from_str::<SessionIndexRecord>(&line) else {
570 continue;
571 };
572 if record.event == SessionIndexEventKind::Start
573 && record.cwd.as_deref() == Some(plan.preview.project_root.as_path())
574 {
575 scoped.insert(record.session_id);
576 }
577 }
578
579 let project_id = ProjectId::parse(&plan.preview.project_id)
580 .map_err(|_| HistoryImportError::InvalidIdentity)?;
581 let redactor = |text: &str| redact_import_text(text);
582 let mut ids: Vec<String> = agent_core::session_journal::session_dir_entries(sessions_dir)
583 .map_err(|_| HistoryImportError::SessionIndexReadFailed)?
584 .into_iter()
585 .filter_map(|entry| entry.name.strip_suffix(".json").map(str::to_owned))
586 .filter(|id| scoped.contains(id))
587 .collect();
588 ids.sort();
589
590 let mut submitted_in_batch = 0_usize;
591 for id in ids {
592 if cancellation.is_cancelled() {
593 return Err(HistoryImportError::Cancelled);
594 }
595 let session = agent_core::session::Session::load_from_dir(sessions_dir, &id)
596 .map_err(|_| HistoryImportError::SessionLoadFailed)?;
597 report.sessions_loaded += 1;
598 let session_id =
599 SessionId::parse(&session.id).map_err(|_| HistoryImportError::InvalidIdentity)?;
600 let started_at: SystemTime = session.created_at.into();
601 let completed_at: SystemTime = session.updated_at.into();
602 let mut pending_user: Option<String> = None;
603 let mut ordinal = 0_u64;
604
605 for message in &session.api_messages {
606 if cancellation.is_cancelled() {
607 return Err(HistoryImportError::Cancelled);
608 }
609 let role = message.get("role").and_then(serde_json::Value::as_str);
610 let Some(text) = import_message_text(message) else {
611 continue;
612 };
613 match role {
614 Some("user") => pending_user = Some(text),
615 Some("assistant") => {
616 let Some(user) = pending_user.take() else {
617 continue;
618 };
619 ordinal = ordinal.saturating_add(1);
620 let disclosure = DisclosureClass::ModelVisibleAfterRedaction;
621 if !may_persist(disclosure) {
622 continue;
623 }
624 let user = match gate_for_model(disclosure, &user, false, Some(&redactor)) {
625 ModelVisibility::Visible(text) => text,
626 ModelVisibility::Withheld(_) => continue,
627 };
628 let assistant =
629 match gate_for_model(disclosure, &text, false, Some(&redactor)) {
630 ModelVisibility::Visible(text) => text,
631 ModelVisibility::Withheld(_) => continue,
632 };
633 let history = TerminalTurnHistory {
634 project_id: project_id.clone(),
635 session_id: session_id.clone(),
636 turn_id: TurnId::parse(&format!("import-{ordinal}"))
637 .map_err(|_| HistoryImportError::InvalidIdentity)?,
638 turn_ordinal: ordinal,
639 started_at,
640 completed_at: completed_at.max(started_at + Duration::from_nanos(1)),
641 outcome: agent_core::TurnOutcome::Completed,
642 items: vec![
643 CanonicalCaptureItem {
644 project_id: project_id.clone(),
645 class: CaptureContentClass::UserMessage,
646 disclosure,
647 sensitivity: Sensitivity::Normal,
648 text: user,
649 tool_name: None,
650 },
651 CanonicalCaptureItem {
652 project_id: project_id.clone(),
653 class: CaptureContentClass::AssistantFinal,
654 disclosure,
655 sensitivity: Sensitivity::Normal,
656 text: assistant,
657 tool_name: None,
658 },
659 ],
660 compaction: None,
661 };
662 let capture =
663 build_chat_turn_capture(&project_id, history, RetentionClass::Standard)
664 .map_err(|_| HistoryImportError::CaptureBuildFailed)?;
665 report.captures_built += 1;
666 let digest = capture.source_digest.to_hex();
667 if committed.contains(&digest) {
668 report.ranges_skipped += 1;
669 continue;
670 }
671 let capture_bytes = capture
672 .user
673 .content
674 .text
675 .len()
676 .saturating_add(capture.assistant.content.text.len())
677 as u64;
678 let (receipt_tx, receipt_rx) = mpsc::channel();
679 if !worker.submit_built_with_receipt(
680 lease,
681 capture,
682 provider.clone(),
683 receipt_tx,
684 ) {
685 return Err(HistoryImportError::CaptureQueueFull);
686 }
687 loop {
688 match receipt_rx.try_recv() {
689 Ok(Ok(())) => break,
690 Ok(Err(_)) | Err(mpsc::TryRecvError::Disconnected) => {
691 return Err(HistoryImportError::ProviderCaptureFailed)
692 }
693 Err(mpsc::TryRecvError::Empty) if cancellation.is_cancelled() => {
694 return Err(HistoryImportError::Cancelled)
695 }
696 Err(mpsc::TryRecvError::Empty) => {
697 std::thread::sleep(Duration::from_millis(2));
698 }
699 }
700 }
701 if cancellation.is_cancelled() {
706 return Err(HistoryImportError::Cancelled);
707 }
708
709 committed.insert(digest);
710 persist_import_checkpoint(checkpoint_path, &key, &committed)?;
711 bytes_processed = bytes_processed.saturating_add(capture_bytes);
712 submitted_in_batch += 1;
713 if submitted_in_batch == batch_size {
714 report.batches_submitted += 1;
715 submitted_in_batch = 0;
716 }
717 emit(HistoryImportEvent::progress(
718 committed.len(),
719 plan.preview.session_count,
720 bytes_processed,
721 started.elapsed(),
722 ));
723 }
724 _ => {}
725 }
726 }
727 }
728 if submitted_in_batch != 0 {
729 report.batches_submitted += 1;
730 }
731 Ok(report)
732 })();
733
734 if let Err(error) = &run {
735 emit(HistoryImportEvent::error(
736 committed.len(),
737 bytes_processed,
738 started.elapsed(),
739 error.clone(),
740 ));
741 }
742 run
743}
744
745fn import_checkpoint_key(project_id: &str) -> String {
746 use sha2::{Digest, Sha256};
747 let mut digest = Sha256::new();
748 digest.update(b"synaps-memory-history-checkpoint-v1\0");
749 digest.update(project_id.as_bytes());
750 digest
751 .finalize()
752 .iter()
753 .map(|byte| format!("{byte:02x}"))
754 .collect()
755}
756
757fn load_import_checkpoint(
758 path: &std::path::Path,
759 key: &str,
760) -> Result<std::collections::HashSet<String>, HistoryImportError> {
761 let bytes = match std::fs::read(path) {
762 Ok(bytes) => bytes,
763 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Default::default()),
764 Err(_) => return Err(HistoryImportError::CheckpointReadFailed),
765 };
766 if bytes.len() as u64 > CHECKPOINT_FILE_MAX_BYTES {
767 return Err(HistoryImportError::CheckpointReadFailed);
768 }
769 let checkpoint: ImportCheckpoint =
770 serde_json::from_slice(&bytes).map_err(|_| HistoryImportError::CheckpointReadFailed)?;
771 if checkpoint.version != 1 {
772 return Err(HistoryImportError::CheckpointReadFailed);
773 }
774 if checkpoint.key != key {
775 return Ok(Default::default());
776 }
777 if checkpoint
778 .committed_source_digests
779 .iter()
780 .any(|digest| digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()))
781 {
782 return Err(HistoryImportError::CheckpointReadFailed);
783 }
784 Ok(checkpoint.committed_source_digests.into_iter().collect())
785}
786
787fn persist_import_checkpoint(
788 path: &std::path::Path,
789 key: &str,
790 committed: &std::collections::HashSet<String>,
791) -> Result<(), HistoryImportError> {
792 use std::io::Write;
793 let mut digests: Vec<_> = committed.iter().cloned().collect();
794 digests.sort();
795 let bytes = serde_json::to_vec(&ImportCheckpoint {
796 version: 1,
797 key: key.to_owned(),
798 committed_source_digests: digests,
799 })
800 .map_err(|_| HistoryImportError::CheckpointWriteFailed)?;
801 if bytes.len() as u64 > CHECKPOINT_FILE_MAX_BYTES {
802 return Err(HistoryImportError::CheckpointWriteFailed);
803 }
804 let parent = path
805 .parent()
806 .ok_or(HistoryImportError::CheckpointWriteFailed)?;
807 std::fs::create_dir_all(parent).map_err(|_| HistoryImportError::CheckpointWriteFailed)?;
808 #[cfg(unix)]
809 {
810 use std::os::unix::fs::PermissionsExt;
811 std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
812 .map_err(|_| HistoryImportError::CheckpointWriteFailed)?;
813 }
814 let mut temporary = tempfile::NamedTempFile::new_in(parent)
815 .map_err(|_| HistoryImportError::CheckpointWriteFailed)?;
816 temporary
817 .write_all(&bytes)
818 .and_then(|_| temporary.as_file_mut().sync_all())
819 .map_err(|_| HistoryImportError::CheckpointWriteFailed)?;
820 #[cfg(unix)]
821 temporary
822 .as_file()
823 .set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600))
824 .map_err(|_| HistoryImportError::CheckpointWriteFailed)?;
825 temporary
826 .persist(path)
827 .map_err(|_| HistoryImportError::CheckpointWriteFailed)?;
828 if let Ok(directory) = std::fs::File::open(parent) {
829 let _ = directory.sync_all();
830 }
831 Ok(())
832}
833
834#[cfg(test)]
835fn load_import_checkpoint_for_test(
836 path: &std::path::Path,
837) -> Result<std::collections::HashSet<String>, HistoryImportError> {
838 let bytes = match std::fs::read(path) {
839 Ok(bytes) => bytes,
840 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Default::default()),
841 Err(_) => return Err(HistoryImportError::CheckpointReadFailed),
842 };
843 let checkpoint: ImportCheckpoint =
844 serde_json::from_slice(&bytes).map_err(|_| HistoryImportError::CheckpointReadFailed)?;
845 Ok(checkpoint.committed_source_digests.into_iter().collect())
846}
847
848fn submit_import_batch(
849 worker: &crate::runtime::capture_worker::CaptureWorker,
850 lease: &crate::runtime::memory_context::MemoryContextLease,
851 provider: std::sync::Arc<dyn crate::runtime::capture_worker::CaptureProvider>,
852 captures: &mut Vec<crate::runtime::chat_capture::ChatTurnCapture>,
853) -> Result<(), HistoryImportError> {
854 for capture in captures.drain(..) {
855 if !worker.submit_built(lease, capture, provider.clone()) {
856 return Err(HistoryImportError::CaptureQueueFull);
857 }
858 }
859 Ok(())
860}
861
862fn import_message_text(message: &serde_json::Value) -> Option<String> {
863 match message.get("content")? {
864 serde_json::Value::String(text) => (!text.trim().is_empty()).then(|| text.clone()),
865 serde_json::Value::Array(blocks) => {
866 let text = blocks
867 .iter()
868 .filter(|block| {
869 block.get("type").and_then(serde_json::Value::as_str) == Some("text")
870 })
871 .filter_map(|block| block.get("text").and_then(serde_json::Value::as_str))
872 .collect::<Vec<_>>()
873 .join("\n");
874 (!text.trim().is_empty()).then_some(text)
875 }
876 _ => None,
877 }
878}
879
880fn redact_import_text(text: &str) -> String {
881 text.split_whitespace()
882 .map(|word| {
883 let lower = word.to_ascii_lowercase();
884 if ["password=", "token=", "secret=", "api_key="]
885 .iter()
886 .any(|prefix| lower.starts_with(prefix))
887 {
888 "[REDACTED]"
889 } else {
890 word
891 }
892 })
893 .collect::<Vec<_>>()
894 .join(" ")
895}
896
897impl HistoryImportHostState {
898 pub fn from_current_host() -> Result<Self, HistoryImportError> {
900 let cwd = std::env::current_dir().map_err(|_| HistoryImportError::HostStateUnavailable)?;
901 let scope = agent_core::memory::store::ProjectScope::discover(&cwd)
902 .map_err(|_| HistoryImportError::HostStateUnavailable)?;
903 Ok(Self {
904 project_id: scope.key().to_owned(),
905 project_root: scope.root().to_path_buf(),
906 destination_r8_path: std::env::var_os("AXEL_BRAIN")
907 .map(PathBuf::from)
908 .or_else(|| {
909 std::env::var_os("SYNAPS_DATA_DIR")
910 .map(PathBuf::from)
911 .map(|dir| dir.join("axel.r8"))
912 })
913 .or_else(|| {
914 std::env::var_os("HOME")
915 .map(PathBuf::from)
916 .map(|home| home.join(".config/axel/axel.r8"))
917 })
918 .unwrap_or_else(|| scope.root().join("axel.r8")),
919 })
920 }
921}
922
923pub struct CanonicalHistoryMetadataIo {
926 sessions_dir: PathBuf,
927}
928
929impl CanonicalHistoryMetadataIo {
930 pub fn new() -> Self {
931 Self {
932 sessions_dir: agent_core::config::get_active_config_dir().join("sessions"),
933 }
934 }
935}
936
937impl Default for CanonicalHistoryMetadataIo {
938 fn default() -> Self {
939 Self::new()
940 }
941}
942
943impl HistoryImportIo for CanonicalHistoryMetadataIo {
944 fn scan_metadata(&mut self) -> Result<Vec<HistorySessionMetadata>, HistoryImportError> {
945 let entries = agent_core::session_journal::session_dir_entries(&self.sessions_dir)
946 .map_err(|_| HistoryImportError::MetadataScanFailed)?;
947 Ok(entries
948 .into_iter()
949 .filter(|entry| entry.name.ends_with(".json"))
950 .map(|entry| {
951 let id = entry.name.trim_end_matches(".json").to_owned();
952 let started_at = entry
953 .mtime
954 .map(chrono::DateTime::<chrono::Utc>::from)
955 .map(|time| time.to_rfc3339())
956 .unwrap_or_else(|| "unknown".to_string());
957 HistorySessionMetadata {
958 id,
959 approx_bytes: entry.byte_len,
960 started_at,
961 }
962 })
963 .collect())
964 }
965
966 fn read_session_content(&mut self, _id: &str) -> Result<(), HistoryImportError> {
967 Err(HistoryImportError::ConsentRequired)
968 }
969
970 fn write_axel(&mut self) -> Result<(), HistoryImportError> {
971 Err(HistoryImportError::ConsentRequired)
972 }
973}
974
975pub fn preview_history_import(
977 host: &HistoryImportHostState,
978 io: &mut impl HistoryImportIo,
979) -> Result<HistoryImportPreview, HistoryImportError> {
980 let sessions = io.scan_metadata()?;
981 let approx_bytes = sessions.iter().fold(0_u64, |total, session| {
982 total.saturating_add(session.approx_bytes)
983 });
984 let included_date_range = sessions
985 .iter()
986 .map(|session| session.started_at.as_str())
987 .min()
988 .zip(
989 sessions
990 .iter()
991 .map(|session| session.started_at.as_str())
992 .max(),
993 )
994 .map(|(earliest, latest)| IncludedDateRange {
995 earliest: earliest.to_owned(),
996 latest: latest.to_owned(),
997 });
998
999 Ok(HistoryImportPreview {
1000 project_id: host.project_id.clone(),
1001 project_root: host.project_root.clone(),
1002 session_count: sessions.len(),
1003 approx_bytes,
1004 included_date_range,
1005 included_content_classes: vec![
1006 "user_messages",
1007 "assistant_final_messages",
1008 "eligible_tool_outcome_summaries",
1009 "typed_terminal_outcomes",
1010 "session_turn_time_provenance",
1011 "compaction_linkage",
1012 ],
1013 excluded_content_classes: vec![
1014 "system_and_developer_prompts",
1015 "private_reasoning",
1016 "credentials_and_secret_prompt_responses",
1017 "raw_binary_attachments",
1018 "unbounded_tool_output",
1019 "never_persist_content",
1020 "foreign_project_content",
1021 ],
1022 retention_policy: "project-scoped local retention; never_persist content is excluded"
1023 .to_string(),
1024 redaction_policy:
1025 "host disclosure filtering, secret exclusion, and bounded tool-result summaries"
1026 .to_string(),
1027 destination_r8_path: host.destination_r8_path.clone(),
1028 explicit_confirmation_required: true,
1029 })
1030}
1031
1032pub fn authorize_history_import(
1034 preview: HistoryImportPreview,
1035 consent: HistoryImportConsent,
1036 authority: RequestAuthority,
1037 _io: &mut impl HistoryImportIo,
1038) -> Result<HistoryImportOutcome, HistoryImportError> {
1039 if authority == RequestAuthority::ModelTool {
1040 return Err(HistoryImportError::ModelCannotConfirm);
1041 }
1042
1043 match consent {
1044 HistoryImportConsent::Declined => Ok(HistoryImportOutcome::Declined),
1045 HistoryImportConsent::Confirmed(intent) => Ok(HistoryImportOutcome::Ready(ImportPlan {
1046 confirmation_id: intent.confirmation_id().to_owned(),
1047 user_intent: intent.proof,
1048 preview,
1049 })),
1050 }
1051}
1052
1053pub fn propose_history_import(
1055 host: &HistoryImportHostState,
1056 io: &mut impl HistoryImportIo,
1057) -> Result<HistoryImportPreview, HistoryImportError> {
1058 preview_history_import(host, io)
1059}
1060
1061pub fn index_history(
1063 host: &HistoryImportHostState,
1064 consent: HistoryImportConsent,
1065 authority: RequestAuthority,
1066 io: &mut impl HistoryImportIo,
1067) -> Result<HistoryImportOutcome, HistoryImportError> {
1068 if authority == RequestAuthority::ModelTool {
1070 return Err(HistoryImportError::ModelCannotConfirm);
1071 }
1072 let preview = preview_history_import(host, io)?;
1073 authorize_history_import(preview, consent, authority, io)
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078 use super::*;
1079 use std::path::PathBuf;
1080
1081 #[derive(Default)]
1082 struct AccountingIo {
1083 metadata_scans: usize,
1084 content_reads: usize,
1085 axel_writes: usize,
1086 }
1087
1088 impl HistoryImportIo for AccountingIo {
1089 fn scan_metadata(&mut self) -> Result<Vec<HistorySessionMetadata>, HistoryImportError> {
1090 self.metadata_scans += 1;
1091 Ok(vec![
1092 HistorySessionMetadata {
1093 id: "session-a".into(),
1094 approx_bytes: 120,
1095 started_at: "2025-01-02T03:04:05Z".into(),
1096 },
1097 HistorySessionMetadata {
1098 id: "session-b".into(),
1099 approx_bytes: 80,
1100 started_at: "2025-02-03T04:05:06Z".into(),
1101 },
1102 ])
1103 }
1104
1105 fn read_session_content(&mut self, _id: &str) -> Result<(), HistoryImportError> {
1106 self.content_reads += 1;
1107 Ok(())
1108 }
1109
1110 fn write_axel(&mut self) -> Result<(), HistoryImportError> {
1111 self.axel_writes += 1;
1112 Ok(())
1113 }
1114 }
1115
1116 fn host() -> HistoryImportHostState {
1117 HistoryImportHostState {
1118 project_id: "project-host-owned".into(),
1119 project_root: PathBuf::from("/workspace/project"),
1120 destination_r8_path: PathBuf::from("/workspace/project/.r8/axel-memory"),
1121 }
1122 }
1123
1124 #[test]
1125 fn preview_fields_are_populated_from_host_state() {
1126 let mut io = AccountingIo::default();
1127 let preview = preview_history_import(&host(), &mut io).expect("preview");
1128
1129 assert_eq!(preview.project_id, "project-host-owned");
1130 assert_eq!(preview.project_root, PathBuf::from("/workspace/project"));
1131 assert_eq!(preview.session_count, 2);
1132 assert_eq!(preview.approx_bytes, 200);
1133 assert_eq!(
1134 preview.included_date_range,
1135 Some(IncludedDateRange {
1136 earliest: "2025-01-02T03:04:05Z".into(),
1137 latest: "2025-02-03T04:05:06Z".into(),
1138 })
1139 );
1140 assert!(!preview.included_content_classes.is_empty());
1141 assert!(!preview.excluded_content_classes.is_empty());
1142 assert!(!preview.retention_policy.is_empty());
1143 assert!(!preview.redaction_policy.is_empty());
1144 assert_eq!(
1145 preview.destination_r8_path,
1146 PathBuf::from("/workspace/project/.r8/axel-memory")
1147 );
1148 assert!(preview.explicit_confirmation_required);
1149 assert_eq!(io.metadata_scans, 1);
1150 assert_eq!(io.content_reads, 0);
1151 assert_eq!(io.axel_writes, 0);
1152 }
1153
1154 #[test]
1155 fn declined_consent_does_no_content_reads_or_axel_writes() {
1156 let mut io = AccountingIo::default();
1157 let preview = preview_history_import(&host(), &mut io).expect("preview");
1158 let outcome = authorize_history_import(
1159 preview,
1160 HistoryImportConsent::Declined,
1161 RequestAuthority::UserFrontend,
1162 &mut io,
1163 )
1164 .expect("decline is not an error");
1165
1166 assert_eq!(outcome, HistoryImportOutcome::Declined);
1167 assert_eq!(io.metadata_scans, 1);
1168 assert_eq!(io.content_reads, 0);
1169 assert_eq!(io.axel_writes, 0);
1170 }
1171
1172 #[test]
1173 fn model_self_confirmation_is_denied_before_any_read() {
1174 let mut io = AccountingIo::default();
1175 let result = index_history(
1176 &host(),
1177 HistoryImportConsent::Confirmed(ConfirmedUserIntent::for_test("confirmation-1")),
1178 RequestAuthority::ModelTool,
1179 &mut io,
1180 );
1181
1182 assert_eq!(result, Err(HistoryImportError::ModelCannotConfirm));
1183 assert_eq!(io.metadata_scans, 0);
1184 assert_eq!(io.content_reads, 0);
1185 assert_eq!(io.axel_writes, 0);
1186 }
1187
1188 #[test]
1189 fn model_proposal_returns_preview_but_has_no_confirmation_path() {
1190 let mut io = AccountingIo::default();
1191 let preview = propose_history_import(&host(), &mut io).expect("preview");
1192
1193 assert!(preview.explicit_confirmation_required);
1194 assert_eq!(io.metadata_scans, 1);
1195 assert_eq!(io.content_reads, 0);
1196 assert_eq!(io.axel_writes, 0);
1197 }
1198
1199 #[test]
1200 fn confirmed_user_consent_returns_typed_plan_without_starting_import() {
1201 let mut io = AccountingIo::default();
1202 let outcome = index_history(
1203 &host(),
1204 HistoryImportConsent::Confirmed(ConfirmedUserIntent::for_test("confirmation-2")),
1205 RequestAuthority::UserFrontend,
1206 &mut io,
1207 )
1208 .expect("confirmed plan");
1209
1210 let HistoryImportOutcome::Ready(plan) = outcome else {
1211 panic!("expected typed plan");
1212 };
1213 assert_eq!(plan.preview.session_count, 2);
1214 assert_eq!(plan.confirmation_id, "confirmation-2");
1215 assert_eq!(io.metadata_scans, 1);
1216 assert_eq!(io.content_reads, 0);
1217 assert_eq!(io.axel_writes, 0);
1218 }
1219
1220 mod streaming_import {
1221 use super::*;
1222 use crate::runtime::capture_worker::{CaptureFailure, CaptureProvider, CaptureWorker};
1223 use crate::runtime::chat_capture::ChatTurnCapture;
1224 use crate::runtime::memory_context::{
1225 mint_explicit_command_proof, CapturePolicy, ContextProviderId, MemoryContextLease,
1226 MemoryContextMode, MemoryLeaseId, ProjectId, RecallPolicy, SessionId,
1227 };
1228 use agent_core::session::Session;
1229 use agent_core::session_journal::{save_session_in_dir, SessionPersistence};
1230 use chrono::{TimeZone, Utc};
1231 use serde_json::json;
1232 use std::collections::HashSet;
1233 use std::sync::atomic::{AtomicUsize, Ordering};
1234 use std::sync::{Arc, Mutex};
1235 use std::time::{Duration, Instant, SystemTime};
1236
1237 struct RecordingProvider {
1238 captures: Mutex<Vec<ChatTurnCapture>>,
1239 network_constructions: std::sync::atomic::AtomicUsize,
1240 block: Mutex<Option<std::sync::mpsc::Receiver<()>>>,
1241 }
1242
1243 impl Default for RecordingProvider {
1244 fn default() -> Self {
1245 Self {
1246 captures: Mutex::new(Vec::new()),
1247 network_constructions: std::sync::atomic::AtomicUsize::new(0),
1248 block: Mutex::new(None),
1249 }
1250 }
1251 }
1252
1253 impl CaptureProvider for RecordingProvider {
1254 fn capture(&self, capture: ChatTurnCapture) -> Result<(), CaptureFailure> {
1255 self.captures.lock().expect("captures lock").push(capture);
1256 if let Some(receiver) = self.block.lock().expect("block lock").take() {
1257 let _ = receiver.recv();
1258 }
1259 Ok(())
1260 }
1261 }
1262
1263 fn fixture_session(id: &str, user: &str, assistant: &str) -> Session {
1264 let timestamp = Utc.timestamp_opt(1_700_000_000, 0).unwrap();
1265 Session {
1266 id: id.into(),
1267 title: id.into(),
1268 name: None,
1269 model: "fixture-model".into(),
1270 thinking_level: "brief".into(),
1271 system_prompt: Some("must never import".into()),
1272 created_at: timestamp,
1273 updated_at: timestamp,
1274 total_input_tokens: 0,
1275 total_output_tokens: 0,
1276 session_cost: 0.0,
1277 api_messages: vec![
1278 Arc::new(json!({"role": "user", "content": user})),
1279 Arc::new(json!({"role": "assistant", "content": assistant})),
1280 ],
1281 abort_context: None,
1282 parent_session: None,
1283 compacted_into: None,
1284 prompt_provenance: None,
1285 compaction: None,
1286 }
1287 }
1288
1289 fn append_index(dir: &std::path::Path, id: &str, root: &std::path::Path) {
1290 use std::io::Write;
1291 std::fs::create_dir_all(dir).unwrap();
1292 let mut index = std::fs::OpenOptions::new()
1293 .create(true)
1294 .append(true)
1295 .open(dir.join("index.jsonl"))
1296 .unwrap();
1297 writeln!(
1298 index,
1299 "{}",
1300 json!({
1301 "schema_version": 1,
1302 "session_id": id,
1303 "event": "start",
1304 "timestamp": "2023-11-14T22:13:20Z",
1305 "cwd": root,
1306 })
1307 )
1308 .unwrap();
1309 }
1310
1311 fn plan(root: &std::path::Path, session_count: usize) -> ImportPlan {
1312 ImportPlan {
1313 preview: HistoryImportPreview {
1314 project_id: "project-import".into(),
1315 project_root: root.to_path_buf(),
1316 session_count,
1317 approx_bytes: 1,
1318 included_date_range: None,
1319 included_content_classes: vec!["user_messages", "assistant_final_messages"],
1320 excluded_content_classes: vec!["foreign_project_content"],
1321 retention_policy: "standard".into(),
1322 redaction_policy: "host".into(),
1323 destination_r8_path: root.join("axel.r8"),
1324 explicit_confirmation_required: true,
1325 },
1326 confirmation_id: "confirmed-import".into(),
1327 user_intent: mint_explicit_command_proof(),
1328 }
1329 }
1330
1331 fn lease() -> MemoryContextLease {
1332 let now = SystemTime::now();
1333 MemoryContextLease::grant(
1334 MemoryLeaseId::parse("lease-import").unwrap(),
1335 SessionId::parse("active-session").unwrap(),
1336 ProjectId::parse("project-import").unwrap(),
1337 ContextProviderId::parse("extension:axel:memory").unwrap(),
1338 MemoryContextMode::CaptureOnly,
1339 CapturePolicy::default(),
1340 RecallPolicy::default(),
1341 mint_explicit_command_proof(),
1342 now,
1343 Some(now + Duration::from_secs(60)),
1344 )
1345 .unwrap()
1346 }
1347
1348 fn wait_for(provider: &RecordingProvider, count: usize) {
1349 for _ in 0..100 {
1350 if provider.captures.lock().unwrap().len() >= count {
1351 return;
1352 }
1353 std::thread::sleep(Duration::from_millis(5));
1354 }
1355 panic!("capture worker did not drain");
1356 }
1357
1358 #[test]
1359 fn legacy_json_and_journal_sessions_stream_through_one_host_api() {
1360 let temp = tempfile::TempDir::new().unwrap();
1361 let root = temp.path().join("project");
1362 let sessions = temp.path().join("sessions");
1363 std::fs::create_dir_all(&root).unwrap();
1364
1365 let legacy = fixture_session("legacy", "old user token=secret-sentinel", "old answer");
1366 save_session_in_dir(&sessions, &legacy, SessionPersistence::Json).unwrap();
1367 append_index(&sessions, "legacy", &root);
1368
1369 let mut journal = fixture_session("journal", "journal user", "journal answer");
1370 save_session_in_dir(&sessions, &journal, SessionPersistence::Journal).unwrap();
1371 journal.api_messages.push(Arc::new(json!({
1372 "role": "user", "content": "appended user"
1373 })));
1374 journal.api_messages.push(Arc::new(json!({
1375 "role": "assistant", "content": "appended answer"
1376 })));
1377 save_session_in_dir(&sessions, &journal, SessionPersistence::Journal).unwrap();
1378 append_index(&sessions, "journal", &root);
1379
1380 let provider = Arc::new(RecordingProvider::default());
1381 let worker = CaptureWorker::new(4);
1382 let report = import_history_from_dir(
1383 &plan(&root, 2),
1384 &lease(),
1385 &sessions,
1386 2,
1387 &worker,
1388 provider.clone(),
1389 )
1390 .unwrap();
1391
1392 assert_eq!(report.sessions_loaded, 2);
1393 assert_eq!(report.captures_built, 3);
1394 wait_for(&provider, 3);
1395 let captures = provider.captures.lock().unwrap();
1396 let users: Vec<&str> = captures
1397 .iter()
1398 .map(|c| c.user.content.text.as_str())
1399 .collect();
1400 assert!(users.contains(&"old user [REDACTED]"));
1401 assert!(users.contains(&"journal user"));
1402 assert!(users.contains(&"appended user"));
1403 assert!(captures.iter().all(|capture| !capture
1404 .user
1405 .content
1406 .text
1407 .contains("secret-sentinel")));
1408 assert_eq!(
1409 provider
1410 .network_constructions
1411 .load(std::sync::atomic::Ordering::SeqCst),
1412 0
1413 );
1414 }
1415
1416 #[test]
1417 fn foreign_project_session_is_not_opened_or_disclosed() {
1418 let temp = tempfile::TempDir::new().unwrap();
1419 let root = temp.path().join("project-a");
1420 let foreign_root = temp.path().join("project-b");
1421 let sessions = temp.path().join("sessions");
1422 std::fs::create_dir_all(&root).unwrap();
1423 std::fs::create_dir_all(&foreign_root).unwrap();
1424
1425 let own = fixture_session("own", "own user", "own answer");
1426 save_session_in_dir(&sessions, &own, SessionPersistence::Json).unwrap();
1427 append_index(&sessions, "own", &root);
1428 append_index(&sessions, "foreign-sentinel", &foreign_root);
1429 std::fs::write(
1430 sessions.join("foreign-sentinel.json"),
1431 b"SENTINEL: opening this foreign session must fail parsing",
1432 )
1433 .unwrap();
1434
1435 let provider = Arc::new(RecordingProvider::default());
1436 let worker = CaptureWorker::new(2);
1437 let report = import_history_from_dir(
1438 &plan(&root, 1),
1439 &lease(),
1440 &sessions,
1441 1,
1442 &worker,
1443 provider.clone(),
1444 )
1445 .unwrap();
1446
1447 assert_eq!(report.sessions_loaded, 1);
1448 wait_for(&provider, 1);
1449 let captures = provider.captures.lock().unwrap();
1450 assert_eq!(captures.len(), 1);
1451 assert_eq!(captures[0].session_id.as_str(), "own");
1452 }
1453
1454 #[test]
1455 fn import_batches_are_hard_bounded() {
1456 let temp = tempfile::TempDir::new().unwrap();
1457 let root = temp.path().join("project");
1458 let sessions = temp.path().join("sessions");
1459 std::fs::create_dir_all(&root).unwrap();
1460 let provider = Arc::new(RecordingProvider::default());
1461 let worker = CaptureWorker::new(1);
1462
1463 let error = import_history_from_dir(
1464 &plan(&root, 0),
1465 &lease(),
1466 &sessions,
1467 IMPORT_BATCH_MAX_RECORDS + 1,
1468 &worker,
1469 provider,
1470 )
1471 .unwrap_err();
1472
1473 assert_eq!(error, HistoryImportError::InvalidBatchSize);
1474 }
1475
1476 #[test]
1477 fn import_queue_overflow_is_bounded_and_reported() {
1478 let temp = tempfile::TempDir::new().unwrap();
1479 let root = temp.path().join("project");
1480 let sessions = temp.path().join("sessions");
1481 std::fs::create_dir_all(&root).unwrap();
1482 let session = fixture_session("many", "u1", "a1");
1483 save_session_in_dir(&sessions, &session, SessionPersistence::Json).unwrap();
1484 append_index(&sessions, "many", &root);
1485 let mut session = session;
1487 for (user, assistant) in [("u2", "a2"), ("u3", "a3")] {
1488 session
1489 .api_messages
1490 .push(Arc::new(json!({"role": "user", "content": user})));
1491 session
1492 .api_messages
1493 .push(Arc::new(json!({"role": "assistant", "content": assistant})));
1494 }
1495 save_session_in_dir(&sessions, &session, SessionPersistence::Json).unwrap();
1496
1497 let (release_tx, release_rx) = std::sync::mpsc::channel();
1498 let provider = Arc::new(RecordingProvider {
1499 block: Mutex::new(Some(release_rx)),
1500 ..RecordingProvider::default()
1501 });
1502 let worker = CaptureWorker::new(1);
1503 let error =
1504 import_history_from_dir(&plan(&root, 1), &lease(), &sessions, 3, &worker, provider)
1505 .unwrap_err();
1506 release_tx.send(()).unwrap();
1507
1508 assert_eq!(error, HistoryImportError::CaptureQueueFull);
1509 assert!(worker.overflow_drops() >= 1);
1510 }
1511
1512 struct DurableDigestProvider {
1513 digests: Mutex<HashSet<String>>,
1514 committed: AtomicUsize,
1515 cancel_after: Option<usize>,
1516 cancel: Option<HistoryImportCancellation>,
1517 }
1518
1519 impl CaptureProvider for DurableDigestProvider {
1520 fn capture(&self, capture: ChatTurnCapture) -> Result<(), CaptureFailure> {
1521 let inserted = self
1522 .digests
1523 .lock()
1524 .unwrap()
1525 .insert(capture.source_digest.to_hex());
1526 if inserted {
1527 let committed = self.committed.fetch_add(1, Ordering::SeqCst) + 1;
1528 if self.cancel_after == Some(committed) {
1529 self.cancel.as_ref().unwrap().cancel();
1530 }
1531 }
1532 Ok(())
1533 }
1534
1535 fn contains_capture(
1536 &self,
1537 _capture_id: &[u8; 32],
1538 ) -> Result<crate::runtime::capture_worker::CaptureCommitState, CaptureFailure>
1539 {
1540 Ok(crate::runtime::capture_worker::CaptureCommitState::Absent)
1541 }
1542 }
1543
1544 fn many_turn_session(id: &str, turns: usize, sentinel: &str) -> Session {
1545 let mut session = fixture_session(id, &format!("{sentinel}-u1"), "a1");
1546 for turn in 2..=turns {
1547 session.api_messages.push(Arc::new(json!({
1548 "role": "user", "content": format!("{sentinel}-u{turn}")
1549 })));
1550 session.api_messages.push(Arc::new(json!({
1551 "role": "assistant", "content": format!("a{turn}")
1552 })));
1553 }
1554 session
1555 }
1556
1557 fn wait_until(timeout: Duration, predicate: impl Fn() -> bool) {
1558 let deadline = Instant::now() + timeout;
1559 while !predicate() && Instant::now() < deadline {
1560 std::thread::sleep(Duration::from_millis(2));
1561 }
1562 assert!(predicate());
1563 }
1564
1565 #[test]
1566 fn forced_stop_resumes_last_checkpoint_without_duplicate_source_digests() {
1567 let temp = tempfile::TempDir::new().unwrap();
1568 let root = temp.path().join("project");
1569 let sessions = temp.path().join("sessions");
1570 let checkpoints = temp.path().join("private/import-checkpoints.json");
1571 std::fs::create_dir_all(&root).unwrap();
1572 let session = many_turn_session("resume", 6, "CONTENT_SENTINEL");
1573 save_session_in_dir(&sessions, &session, SessionPersistence::Json).unwrap();
1574 append_index(&sessions, "resume", &root);
1575
1576 let cancel = HistoryImportCancellation::new();
1577 let first_provider = Arc::new(DurableDigestProvider {
1578 digests: Mutex::new(HashSet::new()),
1579 committed: AtomicUsize::new(0),
1580 cancel_after: Some(2),
1581 cancel: Some(cancel.clone()),
1582 });
1583 let worker = CaptureWorker::new(8);
1584 let mut first_events = Vec::new();
1585 let first = import_history_resumable_from_dir(
1586 &plan(&root, 1),
1587 &lease(),
1588 &sessions,
1589 &checkpoints,
1590 1,
1591 &worker,
1592 first_provider.clone(),
1593 &cancel,
1594 &mut |event| first_events.push(event),
1595 );
1596 assert_eq!(first, Err(HistoryImportError::Cancelled));
1597 wait_until(Duration::from_secs(1), || {
1598 first_provider.committed.load(Ordering::SeqCst) == 2
1599 });
1600
1601 let first_digests = first_provider.digests.lock().unwrap().clone();
1602 let resume_provider = Arc::new(DurableDigestProvider {
1603 digests: Mutex::new(first_digests.clone()),
1604 committed: AtomicUsize::new(0),
1605 cancel_after: None,
1606 cancel: None,
1607 });
1608 let resume_worker = CaptureWorker::new(8);
1609 let mut resume_events = Vec::new();
1610 let report = import_history_resumable_from_dir(
1611 &plan(&root, 1),
1612 &lease(),
1613 &sessions,
1614 &checkpoints,
1615 1,
1616 &resume_worker,
1617 resume_provider.clone(),
1618 &HistoryImportCancellation::new(),
1619 &mut |event| resume_events.push(event),
1620 )
1621 .unwrap();
1622 wait_until(Duration::from_secs(1), || {
1623 resume_provider.digests.lock().unwrap().len() == 6
1624 });
1625
1626 let final_digests = resume_provider.digests.lock().unwrap();
1627 assert_eq!(final_digests.len(), 6, "source ranges must be unique");
1628 assert_eq!(report.ranges_skipped, 1, "resume starts at last checkpoint");
1629 assert!(std::fs::metadata(&checkpoints).unwrap().len() <= CHECKPOINT_FILE_MAX_BYTES);
1630 #[cfg(unix)]
1631 assert_eq!(
1632 std::os::unix::fs::PermissionsExt::mode(
1633 &std::fs::metadata(&checkpoints).unwrap().permissions()
1634 ) & 0o777,
1635 0o600
1636 );
1637 }
1638
1639 #[test]
1640 fn cancellation_keeps_checkpoint_consistent_and_does_not_block_producer() {
1641 let temp = tempfile::TempDir::new().unwrap();
1642 let root = temp.path().join("project");
1643 let sessions = temp.path().join("sessions");
1644 let checkpoints = temp.path().join("private/import-checkpoints.json");
1645 std::fs::create_dir_all(&root).unwrap();
1646 let session = many_turn_session("cancel", 20, "CANCEL_SENTINEL");
1647 save_session_in_dir(&sessions, &session, SessionPersistence::Json).unwrap();
1648 append_index(&sessions, "cancel", &root);
1649
1650 let cancel = HistoryImportCancellation::new();
1651 cancel.cancel();
1652 let provider = Arc::new(RecordingProvider::default());
1653 let worker = CaptureWorker::new(1);
1654 let started = Instant::now();
1655 let result = import_history_resumable_from_dir(
1656 &plan(&root, 1),
1657 &lease(),
1658 &sessions,
1659 &checkpoints,
1660 1,
1661 &worker,
1662 provider,
1663 &cancel,
1664 &mut |_| {},
1665 );
1666
1667 assert_eq!(result, Err(HistoryImportError::Cancelled));
1668 assert!(started.elapsed() < Duration::from_millis(100));
1669 assert!(load_import_checkpoint_for_test(&checkpoints)
1670 .unwrap()
1671 .is_empty());
1672 }
1673
1674 #[test]
1675 fn progress_and_error_events_are_metadata_only() {
1676 let event = HistoryImportEvent::progress(3, 7, 1024, Duration::from_millis(9));
1677 let error = HistoryImportEvent::error(
1678 3,
1679 1024,
1680 Duration::from_millis(10),
1681 HistoryImportError::SessionLoadFailed,
1682 );
1683 for serialized in [
1684 serde_json::to_string(&event).unwrap(),
1685 serde_json::to_string(&error).unwrap(),
1686 ] {
1687 assert!(!serialized.contains("CONTENT_SENTINEL"));
1688 assert!(!serialized.contains("project"));
1689 assert!(!serialized.contains("path"));
1690 assert!(!serialized.contains("content"));
1691 }
1692 assert_eq!(event.name, "memory_import.progress");
1693 }
1694 }
1695}