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 message_count: 0,
1278 api_messages: vec![
1279 Arc::new(json!({"role": "user", "content": user})),
1280 Arc::new(json!({"role": "assistant", "content": assistant})),
1281 ],
1282 abort_context: None,
1283 parent_session: None,
1284 compacted_into: None,
1285 prompt_provenance: None,
1286 compaction: None,
1287 }
1288 }
1289
1290 fn append_index(dir: &std::path::Path, id: &str, root: &std::path::Path) {
1291 use std::io::Write;
1292 std::fs::create_dir_all(dir).unwrap();
1293 let mut index = std::fs::OpenOptions::new()
1294 .create(true)
1295 .append(true)
1296 .open(dir.join("index.jsonl"))
1297 .unwrap();
1298 writeln!(
1299 index,
1300 "{}",
1301 json!({
1302 "schema_version": 1,
1303 "session_id": id,
1304 "event": "start",
1305 "timestamp": "2023-11-14T22:13:20Z",
1306 "cwd": root,
1307 })
1308 )
1309 .unwrap();
1310 }
1311
1312 fn plan(root: &std::path::Path, session_count: usize) -> ImportPlan {
1313 ImportPlan {
1314 preview: HistoryImportPreview {
1315 project_id: "project-import".into(),
1316 project_root: root.to_path_buf(),
1317 session_count,
1318 approx_bytes: 1,
1319 included_date_range: None,
1320 included_content_classes: vec!["user_messages", "assistant_final_messages"],
1321 excluded_content_classes: vec!["foreign_project_content"],
1322 retention_policy: "standard".into(),
1323 redaction_policy: "host".into(),
1324 destination_r8_path: root.join("axel.r8"),
1325 explicit_confirmation_required: true,
1326 },
1327 confirmation_id: "confirmed-import".into(),
1328 user_intent: mint_explicit_command_proof(),
1329 }
1330 }
1331
1332 fn lease() -> MemoryContextLease {
1333 let now = SystemTime::now();
1334 MemoryContextLease::grant(
1335 MemoryLeaseId::parse("lease-import").unwrap(),
1336 SessionId::parse("active-session").unwrap(),
1337 ProjectId::parse("project-import").unwrap(),
1338 ContextProviderId::parse("extension:axel:memory").unwrap(),
1339 MemoryContextMode::CaptureOnly,
1340 CapturePolicy::default(),
1341 RecallPolicy::default(),
1342 mint_explicit_command_proof(),
1343 now,
1344 Some(now + Duration::from_secs(60)),
1345 )
1346 .unwrap()
1347 }
1348
1349 fn wait_for(provider: &RecordingProvider, count: usize) {
1350 for _ in 0..100 {
1351 if provider.captures.lock().unwrap().len() >= count {
1352 return;
1353 }
1354 std::thread::sleep(Duration::from_millis(5));
1355 }
1356 panic!("capture worker did not drain");
1357 }
1358
1359 #[test]
1360 fn legacy_json_and_journal_sessions_stream_through_one_host_api() {
1361 let temp = tempfile::TempDir::new().unwrap();
1362 let root = temp.path().join("project");
1363 let sessions = temp.path().join("sessions");
1364 std::fs::create_dir_all(&root).unwrap();
1365
1366 let legacy = fixture_session("legacy", "old user token=secret-sentinel", "old answer");
1367 save_session_in_dir(&sessions, &legacy, SessionPersistence::Json).unwrap();
1368 append_index(&sessions, "legacy", &root);
1369
1370 let mut journal = fixture_session("journal", "journal user", "journal answer");
1371 save_session_in_dir(&sessions, &journal, SessionPersistence::Journal).unwrap();
1372 journal.api_messages.push(Arc::new(json!({
1373 "role": "user", "content": "appended user"
1374 })));
1375 journal.api_messages.push(Arc::new(json!({
1376 "role": "assistant", "content": "appended answer"
1377 })));
1378 save_session_in_dir(&sessions, &journal, SessionPersistence::Journal).unwrap();
1379 append_index(&sessions, "journal", &root);
1380
1381 let provider = Arc::new(RecordingProvider::default());
1382 let worker = CaptureWorker::new(4);
1383 let report = import_history_from_dir(
1384 &plan(&root, 2),
1385 &lease(),
1386 &sessions,
1387 2,
1388 &worker,
1389 provider.clone(),
1390 )
1391 .unwrap();
1392
1393 assert_eq!(report.sessions_loaded, 2);
1394 assert_eq!(report.captures_built, 3);
1395 wait_for(&provider, 3);
1396 let captures = provider.captures.lock().unwrap();
1397 let users: Vec<&str> = captures
1398 .iter()
1399 .map(|c| c.user.content.text.as_str())
1400 .collect();
1401 assert!(users.contains(&"old user [REDACTED]"));
1402 assert!(users.contains(&"journal user"));
1403 assert!(users.contains(&"appended user"));
1404 assert!(captures.iter().all(|capture| !capture
1405 .user
1406 .content
1407 .text
1408 .contains("secret-sentinel")));
1409 assert_eq!(
1410 provider
1411 .network_constructions
1412 .load(std::sync::atomic::Ordering::SeqCst),
1413 0
1414 );
1415 }
1416
1417 #[test]
1418 fn foreign_project_session_is_not_opened_or_disclosed() {
1419 let temp = tempfile::TempDir::new().unwrap();
1420 let root = temp.path().join("project-a");
1421 let foreign_root = temp.path().join("project-b");
1422 let sessions = temp.path().join("sessions");
1423 std::fs::create_dir_all(&root).unwrap();
1424 std::fs::create_dir_all(&foreign_root).unwrap();
1425
1426 let own = fixture_session("own", "own user", "own answer");
1427 save_session_in_dir(&sessions, &own, SessionPersistence::Json).unwrap();
1428 append_index(&sessions, "own", &root);
1429 append_index(&sessions, "foreign-sentinel", &foreign_root);
1430 std::fs::write(
1431 sessions.join("foreign-sentinel.json"),
1432 b"SENTINEL: opening this foreign session must fail parsing",
1433 )
1434 .unwrap();
1435
1436 let provider = Arc::new(RecordingProvider::default());
1437 let worker = CaptureWorker::new(2);
1438 let report = import_history_from_dir(
1439 &plan(&root, 1),
1440 &lease(),
1441 &sessions,
1442 1,
1443 &worker,
1444 provider.clone(),
1445 )
1446 .unwrap();
1447
1448 assert_eq!(report.sessions_loaded, 1);
1449 wait_for(&provider, 1);
1450 let captures = provider.captures.lock().unwrap();
1451 assert_eq!(captures.len(), 1);
1452 assert_eq!(captures[0].session_id.as_str(), "own");
1453 }
1454
1455 #[test]
1456 fn import_batches_are_hard_bounded() {
1457 let temp = tempfile::TempDir::new().unwrap();
1458 let root = temp.path().join("project");
1459 let sessions = temp.path().join("sessions");
1460 std::fs::create_dir_all(&root).unwrap();
1461 let provider = Arc::new(RecordingProvider::default());
1462 let worker = CaptureWorker::new(1);
1463
1464 let error = import_history_from_dir(
1465 &plan(&root, 0),
1466 &lease(),
1467 &sessions,
1468 IMPORT_BATCH_MAX_RECORDS + 1,
1469 &worker,
1470 provider,
1471 )
1472 .unwrap_err();
1473
1474 assert_eq!(error, HistoryImportError::InvalidBatchSize);
1475 }
1476
1477 #[test]
1478 fn import_queue_overflow_is_bounded_and_reported() {
1479 let temp = tempfile::TempDir::new().unwrap();
1480 let root = temp.path().join("project");
1481 let sessions = temp.path().join("sessions");
1482 std::fs::create_dir_all(&root).unwrap();
1483 let session = fixture_session("many", "u1", "a1");
1484 save_session_in_dir(&sessions, &session, SessionPersistence::Json).unwrap();
1485 append_index(&sessions, "many", &root);
1486 let mut session = session;
1488 for (user, assistant) in [("u2", "a2"), ("u3", "a3")] {
1489 session
1490 .api_messages
1491 .push(Arc::new(json!({"role": "user", "content": user})));
1492 session
1493 .api_messages
1494 .push(Arc::new(json!({"role": "assistant", "content": assistant})));
1495 }
1496 save_session_in_dir(&sessions, &session, SessionPersistence::Json).unwrap();
1497
1498 let (release_tx, release_rx) = std::sync::mpsc::channel();
1499 let provider = Arc::new(RecordingProvider {
1500 block: Mutex::new(Some(release_rx)),
1501 ..RecordingProvider::default()
1502 });
1503 let worker = CaptureWorker::new(1);
1504 let error =
1505 import_history_from_dir(&plan(&root, 1), &lease(), &sessions, 3, &worker, provider)
1506 .unwrap_err();
1507 release_tx.send(()).unwrap();
1508
1509 assert_eq!(error, HistoryImportError::CaptureQueueFull);
1510 assert!(worker.overflow_drops() >= 1);
1511 }
1512
1513 struct DurableDigestProvider {
1514 digests: Mutex<HashSet<String>>,
1515 committed: AtomicUsize,
1516 cancel_after: Option<usize>,
1517 cancel: Option<HistoryImportCancellation>,
1518 }
1519
1520 impl CaptureProvider for DurableDigestProvider {
1521 fn capture(&self, capture: ChatTurnCapture) -> Result<(), CaptureFailure> {
1522 let inserted = self
1523 .digests
1524 .lock()
1525 .unwrap()
1526 .insert(capture.source_digest.to_hex());
1527 if inserted {
1528 let committed = self.committed.fetch_add(1, Ordering::SeqCst) + 1;
1529 if self.cancel_after == Some(committed) {
1530 self.cancel.as_ref().unwrap().cancel();
1531 }
1532 }
1533 Ok(())
1534 }
1535
1536 fn contains_capture(
1537 &self,
1538 _capture_id: &[u8; 32],
1539 ) -> Result<crate::runtime::capture_worker::CaptureCommitState, CaptureFailure>
1540 {
1541 Ok(crate::runtime::capture_worker::CaptureCommitState::Absent)
1542 }
1543 }
1544
1545 fn many_turn_session(id: &str, turns: usize, sentinel: &str) -> Session {
1546 let mut session = fixture_session(id, &format!("{sentinel}-u1"), "a1");
1547 for turn in 2..=turns {
1548 session.api_messages.push(Arc::new(json!({
1549 "role": "user", "content": format!("{sentinel}-u{turn}")
1550 })));
1551 session.api_messages.push(Arc::new(json!({
1552 "role": "assistant", "content": format!("a{turn}")
1553 })));
1554 }
1555 session
1556 }
1557
1558 fn wait_until(timeout: Duration, predicate: impl Fn() -> bool) {
1559 let deadline = Instant::now() + timeout;
1560 while !predicate() && Instant::now() < deadline {
1561 std::thread::sleep(Duration::from_millis(2));
1562 }
1563 assert!(predicate());
1564 }
1565
1566 #[test]
1567 fn forced_stop_resumes_last_checkpoint_without_duplicate_source_digests() {
1568 let temp = tempfile::TempDir::new().unwrap();
1569 let root = temp.path().join("project");
1570 let sessions = temp.path().join("sessions");
1571 let checkpoints = temp.path().join("private/import-checkpoints.json");
1572 std::fs::create_dir_all(&root).unwrap();
1573 let session = many_turn_session("resume", 6, "CONTENT_SENTINEL");
1574 save_session_in_dir(&sessions, &session, SessionPersistence::Json).unwrap();
1575 append_index(&sessions, "resume", &root);
1576
1577 let cancel = HistoryImportCancellation::new();
1578 let first_provider = Arc::new(DurableDigestProvider {
1579 digests: Mutex::new(HashSet::new()),
1580 committed: AtomicUsize::new(0),
1581 cancel_after: Some(2),
1582 cancel: Some(cancel.clone()),
1583 });
1584 let worker = CaptureWorker::new(8);
1585 let mut first_events = Vec::new();
1586 let first = import_history_resumable_from_dir(
1587 &plan(&root, 1),
1588 &lease(),
1589 &sessions,
1590 &checkpoints,
1591 1,
1592 &worker,
1593 first_provider.clone(),
1594 &cancel,
1595 &mut |event| first_events.push(event),
1596 );
1597 assert_eq!(first, Err(HistoryImportError::Cancelled));
1598 wait_until(Duration::from_secs(1), || {
1599 first_provider.committed.load(Ordering::SeqCst) == 2
1600 });
1601
1602 let first_digests = first_provider.digests.lock().unwrap().clone();
1603 let resume_provider = Arc::new(DurableDigestProvider {
1604 digests: Mutex::new(first_digests.clone()),
1605 committed: AtomicUsize::new(0),
1606 cancel_after: None,
1607 cancel: None,
1608 });
1609 let resume_worker = CaptureWorker::new(8);
1610 let mut resume_events = Vec::new();
1611 let report = import_history_resumable_from_dir(
1612 &plan(&root, 1),
1613 &lease(),
1614 &sessions,
1615 &checkpoints,
1616 1,
1617 &resume_worker,
1618 resume_provider.clone(),
1619 &HistoryImportCancellation::new(),
1620 &mut |event| resume_events.push(event),
1621 )
1622 .unwrap();
1623 wait_until(Duration::from_secs(1), || {
1624 resume_provider.digests.lock().unwrap().len() == 6
1625 });
1626
1627 let final_digests = resume_provider.digests.lock().unwrap();
1628 assert_eq!(final_digests.len(), 6, "source ranges must be unique");
1629 assert_eq!(report.ranges_skipped, 1, "resume starts at last checkpoint");
1630 assert!(std::fs::metadata(&checkpoints).unwrap().len() <= CHECKPOINT_FILE_MAX_BYTES);
1631 #[cfg(unix)]
1632 assert_eq!(
1633 std::os::unix::fs::PermissionsExt::mode(
1634 &std::fs::metadata(&checkpoints).unwrap().permissions()
1635 ) & 0o777,
1636 0o600
1637 );
1638 }
1639
1640 #[test]
1641 fn cancellation_keeps_checkpoint_consistent_and_does_not_block_producer() {
1642 let temp = tempfile::TempDir::new().unwrap();
1643 let root = temp.path().join("project");
1644 let sessions = temp.path().join("sessions");
1645 let checkpoints = temp.path().join("private/import-checkpoints.json");
1646 std::fs::create_dir_all(&root).unwrap();
1647 let session = many_turn_session("cancel", 20, "CANCEL_SENTINEL");
1648 save_session_in_dir(&sessions, &session, SessionPersistence::Json).unwrap();
1649 append_index(&sessions, "cancel", &root);
1650
1651 let cancel = HistoryImportCancellation::new();
1652 cancel.cancel();
1653 let provider = Arc::new(RecordingProvider::default());
1654 let worker = CaptureWorker::new(1);
1655 let started = Instant::now();
1656 let result = import_history_resumable_from_dir(
1657 &plan(&root, 1),
1658 &lease(),
1659 &sessions,
1660 &checkpoints,
1661 1,
1662 &worker,
1663 provider,
1664 &cancel,
1665 &mut |_| {},
1666 );
1667
1668 assert_eq!(result, Err(HistoryImportError::Cancelled));
1669 assert!(started.elapsed() < Duration::from_millis(100));
1670 assert!(load_import_checkpoint_for_test(&checkpoints)
1671 .unwrap()
1672 .is_empty());
1673 }
1674
1675 #[test]
1676 fn progress_and_error_events_are_metadata_only() {
1677 let event = HistoryImportEvent::progress(3, 7, 1024, Duration::from_millis(9));
1678 let error = HistoryImportEvent::error(
1679 3,
1680 1024,
1681 Duration::from_millis(10),
1682 HistoryImportError::SessionLoadFailed,
1683 );
1684 for serialized in [
1685 serde_json::to_string(&event).unwrap(),
1686 serde_json::to_string(&error).unwrap(),
1687 ] {
1688 assert!(!serialized.contains("CONTENT_SENTINEL"));
1689 assert!(!serialized.contains("project"));
1690 assert!(!serialized.contains("path"));
1691 assert!(!serialized.contains("content"));
1692 }
1693 assert_eq!(event.name, "memory_import.progress");
1694 }
1695 }
1696}