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 lease.provider_id.as_str() == super::axel_context::PROVIDER_ID {
384 return import_history_resumable_from_dir(
387 plan,
388 lease,
389 sessions_dir,
390 &sessions_dir.join("unused-axel-progress"),
391 batch_size,
392 worker,
393 provider,
394 &HistoryImportCancellation::new(),
395 &mut |_| {},
396 );
397 }
398 if batch_size == 0 || batch_size > IMPORT_BATCH_MAX_RECORDS {
399 return Err(HistoryImportError::InvalidBatchSize);
400 }
401 if lease.project_id.as_str() != plan.preview.project_id {
402 return Err(HistoryImportError::InvalidIdentity);
403 }
404
405 let index = match std::fs::File::open(sessions_dir.join("index.jsonl")) {
408 Ok(index) => index,
409 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
410 return Ok(HistoryImportReport::default())
411 }
412 Err(_) => return Err(HistoryImportError::SessionIndexReadFailed),
413 };
414 let mut scoped = HashSet::new();
415 for line in std::io::BufReader::new(index).lines() {
416 let line = line.map_err(|_| HistoryImportError::SessionIndexReadFailed)?;
417 let Ok(record) = serde_json::from_str::<SessionIndexRecord>(&line) else {
418 continue;
419 };
420 if record.event == SessionIndexEventKind::Start
421 && record.cwd.as_deref() == Some(plan.preview.project_root.as_path())
422 {
423 scoped.insert(record.session_id);
424 }
425 }
426
427 let project_id = ProjectId::parse(&plan.preview.project_id)
428 .map_err(|_| HistoryImportError::InvalidIdentity)?;
429 let redactor = |text: &str| redact_import_text(text);
430 let mut captures = Vec::with_capacity(batch_size);
431 let mut report = HistoryImportReport::default();
432
433 let mut ids: Vec<String> = agent_core::session_journal::session_dir_entries(sessions_dir)
436 .map_err(|_| HistoryImportError::SessionIndexReadFailed)?
437 .into_iter()
438 .filter_map(|entry| entry.name.strip_suffix(".json").map(str::to_owned))
439 .filter(|id| scoped.contains(id))
440 .collect();
441 ids.sort();
442
443 for id in ids {
444 let session = agent_core::session::Session::load_from_dir(sessions_dir, &id)
445 .map_err(|_| HistoryImportError::SessionLoadFailed)?;
446 report.sessions_loaded += 1;
447 let session_id =
448 SessionId::parse(&session.id).map_err(|_| HistoryImportError::InvalidIdentity)?;
449 let started_at: SystemTime = session.created_at.into();
450 let completed_at: SystemTime = session.updated_at.into();
451
452 let mut pending_user: Option<String> = None;
453 let mut ordinal = 0_u64;
454 for message in &session.api_messages {
455 let role = message.get("role").and_then(serde_json::Value::as_str);
456 let Some(text) = (if lease.provider_id.as_str() == super::axel_context::PROVIDER_ID {
457 super::axel_context::capture_text(message)
458 } else {
459 import_message_text(message)
460 }) else {
461 continue;
462 };
463 match role {
464 Some("user") => pending_user = Some(text),
465 Some("assistant") => {
466 let Some(user) = pending_user.take() else {
467 continue;
468 };
469 ordinal = ordinal.saturating_add(1);
470 let disclosure = DisclosureClass::ModelVisibleAfterRedaction;
471 if !may_persist(disclosure) {
472 continue;
473 }
474 let user = match gate_for_model(disclosure, &user, false, Some(&redactor)) {
475 ModelVisibility::Visible(text) => text,
476 ModelVisibility::Withheld(_) => continue,
477 };
478 let assistant = match gate_for_model(disclosure, &text, false, Some(&redactor))
479 {
480 ModelVisibility::Visible(text) => text,
481 ModelVisibility::Withheld(_) => continue,
482 };
483 let history = TerminalTurnHistory {
484 project_id: project_id.clone(),
485 session_id: session_id.clone(),
486 turn_id: TurnId::parse(&format!("import-{ordinal}"))
487 .map_err(|_| HistoryImportError::InvalidIdentity)?,
488 turn_ordinal: ordinal,
489 started_at,
490 completed_at: completed_at.max(started_at + Duration::from_nanos(1)),
491 outcome: agent_core::TurnOutcome::Completed,
492 items: vec![
493 CanonicalCaptureItem {
494 project_id: project_id.clone(),
495 class: CaptureContentClass::UserMessage,
496 disclosure,
497 sensitivity: Sensitivity::Normal,
498 text: user,
499 tool_name: None,
500 },
501 CanonicalCaptureItem {
502 project_id: project_id.clone(),
503 class: CaptureContentClass::AssistantFinal,
504 disclosure,
505 sensitivity: Sensitivity::Normal,
506 text: assistant,
507 tool_name: None,
508 },
509 ],
510 compaction: None,
511 };
512 captures.push(
513 build_chat_turn_capture(&project_id, history, RetentionClass::Standard)
514 .map_err(|_| HistoryImportError::CaptureBuildFailed)?,
515 );
516 report.captures_built += 1;
517 if captures.len() == batch_size {
518 submit_import_batch(worker, lease, provider.clone(), &mut captures)?;
519 report.batches_submitted += 1;
520 }
521 }
522 _ => {} }
524 }
525 }
526 if !captures.is_empty() {
527 submit_import_batch(worker, lease, provider, &mut captures)?;
528 report.batches_submitted += 1;
529 }
530 Ok(report)
531}
532
533#[allow(clippy::too_many_arguments)] pub fn import_history_resumable_from_dir(
539 plan: &ImportPlan,
540 lease: &crate::runtime::memory_context::MemoryContextLease,
541 sessions_dir: &std::path::Path,
542 checkpoint_path: &std::path::Path,
543 batch_size: usize,
544 worker: &crate::runtime::capture_worker::CaptureWorker,
545 provider: std::sync::Arc<dyn crate::runtime::capture_worker::CaptureProvider>,
546 cancellation: &HistoryImportCancellation,
547 emit: &mut impl FnMut(HistoryImportEvent),
548) -> Result<HistoryImportReport, HistoryImportError> {
549 use crate::runtime::chat_capture::{
550 build_chat_turn_capture, CanonicalCaptureItem, CaptureContentClass, Sensitivity,
551 TerminalTurnHistory,
552 };
553 use crate::runtime::memory_context::{ProjectId, RetentionClass, SessionId, TurnId};
554 use agent_core::core::disclosure::{
555 gate_for_model, may_persist, DisclosureClass, ModelVisibility,
556 };
557 use agent_core::core::session_index::{SessionIndexEventKind, SessionIndexRecord};
558 use std::collections::HashSet;
559 use std::io::BufRead;
560 use std::sync::mpsc;
561 use std::time::{Duration, Instant, SystemTime};
562
563 let started = Instant::now();
564 let mut report = HistoryImportReport::default();
565 let mut bytes_processed = 0_u64;
566 let key = import_checkpoint_key(&plan.preview.project_id);
567 let axel_authority = lease.provider_id.as_str() == super::axel_context::PROVIDER_ID;
568 let mut committed = if axel_authority {
571 Default::default()
572 } else {
573 load_import_checkpoint(checkpoint_path, &key)?
574 };
575
576 let run = (|| {
577 if batch_size == 0 || batch_size > IMPORT_BATCH_MAX_RECORDS {
578 return Err(HistoryImportError::InvalidBatchSize);
579 }
580 if lease.project_id.as_str() != plan.preview.project_id {
581 return Err(HistoryImportError::InvalidIdentity);
582 }
583 if cancellation.is_cancelled() {
584 return Err(HistoryImportError::Cancelled);
585 }
586
587 let index = match std::fs::File::open(sessions_dir.join("index.jsonl")) {
588 Ok(index) => index,
589 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(report),
590 Err(_) => return Err(HistoryImportError::SessionIndexReadFailed),
591 };
592 let mut scoped = HashSet::new();
593 for line in std::io::BufReader::new(index).lines() {
594 let line = line.map_err(|_| HistoryImportError::SessionIndexReadFailed)?;
595 let Ok(record) = serde_json::from_str::<SessionIndexRecord>(&line) else {
596 continue;
597 };
598 if record.event == SessionIndexEventKind::Start
599 && record.cwd.as_deref() == Some(plan.preview.project_root.as_path())
600 {
601 scoped.insert(record.session_id);
602 }
603 }
604
605 let project_id = ProjectId::parse(&plan.preview.project_id)
606 .map_err(|_| HistoryImportError::InvalidIdentity)?;
607 let redactor = |text: &str| redact_import_text(text);
608 let mut ids: Vec<String> = agent_core::session_journal::session_dir_entries(sessions_dir)
609 .map_err(|_| HistoryImportError::SessionIndexReadFailed)?
610 .into_iter()
611 .filter_map(|entry| entry.name.strip_suffix(".json").map(str::to_owned))
612 .filter(|id| scoped.contains(id))
613 .collect();
614 ids.sort();
615
616 let mut submitted_in_batch = 0_usize;
617 for id in ids {
618 if cancellation.is_cancelled() {
619 return Err(HistoryImportError::Cancelled);
620 }
621 let session = agent_core::session::Session::load_from_dir(sessions_dir, &id)
622 .map_err(|_| HistoryImportError::SessionLoadFailed)?;
623 report.sessions_loaded += 1;
624 let session_id =
625 SessionId::parse(&session.id).map_err(|_| HistoryImportError::InvalidIdentity)?;
626 let started_at: SystemTime = session.created_at.into();
627 let completed_at: SystemTime = session.updated_at.into();
628 let mut pending_user: Option<String> = None;
629 let mut ordinal = 0_u64;
630
631 for message in &session.api_messages {
632 if cancellation.is_cancelled() {
633 return Err(HistoryImportError::Cancelled);
634 }
635 let role = message.get("role").and_then(serde_json::Value::as_str);
636 let Some(text) = (if lease.provider_id.as_str() == super::axel_context::PROVIDER_ID
637 {
638 super::axel_context::capture_text(message)
639 } else {
640 import_message_text(message)
641 }) else {
642 continue;
643 };
644 match role {
645 Some("user") => pending_user = Some(text),
646 Some("assistant") => {
647 let Some(user) = pending_user.take() else {
648 continue;
649 };
650 ordinal = ordinal.saturating_add(1);
651 let disclosure = DisclosureClass::ModelVisibleAfterRedaction;
652 if !may_persist(disclosure) {
653 continue;
654 }
655 let user = match gate_for_model(disclosure, &user, false, Some(&redactor)) {
656 ModelVisibility::Visible(text) => text,
657 ModelVisibility::Withheld(_) => continue,
658 };
659 let assistant =
660 match gate_for_model(disclosure, &text, false, Some(&redactor)) {
661 ModelVisibility::Visible(text) => text,
662 ModelVisibility::Withheld(_) => continue,
663 };
664 let history = TerminalTurnHistory {
665 project_id: project_id.clone(),
666 session_id: session_id.clone(),
667 turn_id: TurnId::parse(&format!("import-{ordinal}"))
668 .map_err(|_| HistoryImportError::InvalidIdentity)?,
669 turn_ordinal: ordinal,
670 started_at,
671 completed_at: completed_at.max(started_at + Duration::from_nanos(1)),
672 outcome: agent_core::TurnOutcome::Completed,
673 items: vec![
674 CanonicalCaptureItem {
675 project_id: project_id.clone(),
676 class: CaptureContentClass::UserMessage,
677 disclosure,
678 sensitivity: Sensitivity::Normal,
679 text: user,
680 tool_name: None,
681 },
682 CanonicalCaptureItem {
683 project_id: project_id.clone(),
684 class: CaptureContentClass::AssistantFinal,
685 disclosure,
686 sensitivity: Sensitivity::Normal,
687 text: assistant,
688 tool_name: None,
689 },
690 ],
691 compaction: None,
692 };
693 let capture =
694 build_chat_turn_capture(&project_id, history, RetentionClass::Standard)
695 .map_err(|_| HistoryImportError::CaptureBuildFailed)?;
696 report.captures_built += 1;
697 let digest = capture.source_digest.to_hex();
698 if committed.contains(&digest) {
699 report.ranges_skipped += 1;
700 continue;
701 }
702 if axel_authority {
703 let capture_id = *capture.capture_id.as_bytes();
704 let query_provider = provider.clone();
705 let state = std::thread::spawn(move || {
706 query_provider.contains_capture(&capture_id)
707 })
708 .join()
709 .map_err(|_| HistoryImportError::ProviderCaptureFailed)?;
710 match state {
711 Ok(
712 crate::runtime::capture_worker::CaptureCommitState::Committed,
713 ) => {
714 committed.insert(digest);
715 report.ranges_skipped += 1;
716 continue;
717 }
718 Ok(crate::runtime::capture_worker::CaptureCommitState::Absent) => {}
719 Err(_) => return Err(HistoryImportError::ProviderCaptureFailed),
720 }
721 }
722 let capture_bytes = capture
723 .user
724 .content
725 .text
726 .len()
727 .saturating_add(capture.assistant.content.text.len())
728 as u64;
729 let (receipt_tx, receipt_rx) = mpsc::channel();
730 if !worker.submit_built_with_receipt(
731 lease,
732 capture,
733 provider.clone(),
734 receipt_tx,
735 ) {
736 return Err(HistoryImportError::CaptureQueueFull);
737 }
738 loop {
739 match receipt_rx.try_recv() {
740 Ok(Ok(())) => break,
741 Ok(Err(_)) | Err(mpsc::TryRecvError::Disconnected) => {
742 return Err(HistoryImportError::ProviderCaptureFailed)
743 }
744 Err(mpsc::TryRecvError::Empty) if cancellation.is_cancelled() => {
745 return Err(HistoryImportError::Cancelled)
746 }
747 Err(mpsc::TryRecvError::Empty) => {
748 std::thread::sleep(Duration::from_millis(2));
749 }
750 }
751 }
752 if cancellation.is_cancelled() {
757 return Err(HistoryImportError::Cancelled);
758 }
759
760 committed.insert(digest);
761 if !axel_authority {
762 persist_import_checkpoint(checkpoint_path, &key, &committed)?;
763 }
764 bytes_processed = bytes_processed.saturating_add(capture_bytes);
765 submitted_in_batch += 1;
766 if submitted_in_batch == batch_size {
767 report.batches_submitted += 1;
768 submitted_in_batch = 0;
769 }
770 emit(HistoryImportEvent::progress(
771 committed.len(),
772 plan.preview.session_count,
773 bytes_processed,
774 started.elapsed(),
775 ));
776 }
777 _ => {}
778 }
779 }
780 }
781 if submitted_in_batch != 0 {
782 report.batches_submitted += 1;
783 }
784 Ok(report)
785 })();
786
787 if let Err(error) = &run {
788 emit(HistoryImportEvent::error(
789 committed.len(),
790 bytes_processed,
791 started.elapsed(),
792 error.clone(),
793 ));
794 }
795 run
796}
797
798fn import_checkpoint_key(project_id: &str) -> String {
799 use sha2::{Digest, Sha256};
800 let mut digest = Sha256::new();
801 digest.update(b"synaps-memory-history-checkpoint-v1\0");
802 digest.update(project_id.as_bytes());
803 digest
804 .finalize()
805 .iter()
806 .map(|byte| format!("{byte:02x}"))
807 .collect()
808}
809
810fn load_import_checkpoint(
811 path: &std::path::Path,
812 key: &str,
813) -> Result<std::collections::HashSet<String>, HistoryImportError> {
814 let bytes = match std::fs::read(path) {
815 Ok(bytes) => bytes,
816 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Default::default()),
817 Err(_) => return Err(HistoryImportError::CheckpointReadFailed),
818 };
819 if bytes.len() as u64 > CHECKPOINT_FILE_MAX_BYTES {
820 return Err(HistoryImportError::CheckpointReadFailed);
821 }
822 let checkpoint: ImportCheckpoint =
823 serde_json::from_slice(&bytes).map_err(|_| HistoryImportError::CheckpointReadFailed)?;
824 if checkpoint.version != 1 {
825 return Err(HistoryImportError::CheckpointReadFailed);
826 }
827 if checkpoint.key != key {
828 return Ok(Default::default());
829 }
830 if checkpoint
831 .committed_source_digests
832 .iter()
833 .any(|digest| digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()))
834 {
835 return Err(HistoryImportError::CheckpointReadFailed);
836 }
837 Ok(checkpoint.committed_source_digests.into_iter().collect())
838}
839
840fn persist_import_checkpoint(
841 path: &std::path::Path,
842 key: &str,
843 committed: &std::collections::HashSet<String>,
844) -> Result<(), HistoryImportError> {
845 use std::io::Write;
846 let mut digests: Vec<_> = committed.iter().cloned().collect();
847 digests.sort();
848 let bytes = serde_json::to_vec(&ImportCheckpoint {
849 version: 1,
850 key: key.to_owned(),
851 committed_source_digests: digests,
852 })
853 .map_err(|_| HistoryImportError::CheckpointWriteFailed)?;
854 if bytes.len() as u64 > CHECKPOINT_FILE_MAX_BYTES {
855 return Err(HistoryImportError::CheckpointWriteFailed);
856 }
857 let parent = path
858 .parent()
859 .ok_or(HistoryImportError::CheckpointWriteFailed)?;
860 std::fs::create_dir_all(parent).map_err(|_| HistoryImportError::CheckpointWriteFailed)?;
861 #[cfg(unix)]
862 {
863 use std::os::unix::fs::PermissionsExt;
864 std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
865 .map_err(|_| HistoryImportError::CheckpointWriteFailed)?;
866 }
867 let mut temporary = tempfile::NamedTempFile::new_in(parent)
868 .map_err(|_| HistoryImportError::CheckpointWriteFailed)?;
869 temporary
870 .write_all(&bytes)
871 .and_then(|_| temporary.as_file_mut().sync_all())
872 .map_err(|_| HistoryImportError::CheckpointWriteFailed)?;
873 #[cfg(unix)]
874 temporary
875 .as_file()
876 .set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600))
877 .map_err(|_| HistoryImportError::CheckpointWriteFailed)?;
878 temporary
879 .persist(path)
880 .map_err(|_| HistoryImportError::CheckpointWriteFailed)?;
881 if let Ok(directory) = std::fs::File::open(parent) {
882 let _ = directory.sync_all();
883 }
884 Ok(())
885}
886
887#[cfg(test)]
888fn load_import_checkpoint_for_test(
889 path: &std::path::Path,
890) -> Result<std::collections::HashSet<String>, HistoryImportError> {
891 let bytes = match std::fs::read(path) {
892 Ok(bytes) => bytes,
893 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Default::default()),
894 Err(_) => return Err(HistoryImportError::CheckpointReadFailed),
895 };
896 let checkpoint: ImportCheckpoint =
897 serde_json::from_slice(&bytes).map_err(|_| HistoryImportError::CheckpointReadFailed)?;
898 Ok(checkpoint.committed_source_digests.into_iter().collect())
899}
900
901fn submit_import_batch(
902 worker: &crate::runtime::capture_worker::CaptureWorker,
903 lease: &crate::runtime::memory_context::MemoryContextLease,
904 provider: std::sync::Arc<dyn crate::runtime::capture_worker::CaptureProvider>,
905 captures: &mut Vec<crate::runtime::chat_capture::ChatTurnCapture>,
906) -> Result<(), HistoryImportError> {
907 for capture in captures.drain(..) {
908 if !worker.submit_built(lease, capture, provider.clone()) {
909 return Err(HistoryImportError::CaptureQueueFull);
910 }
911 }
912 Ok(())
913}
914
915fn import_message_text(message: &serde_json::Value) -> Option<String> {
916 match message.get("content")? {
917 serde_json::Value::String(text) => (!text.trim().is_empty()).then(|| text.clone()),
918 serde_json::Value::Array(blocks) => {
919 let text = blocks
920 .iter()
921 .filter(|block| {
922 block.get("type").and_then(serde_json::Value::as_str) == Some("text")
923 })
924 .filter_map(|block| block.get("text").and_then(serde_json::Value::as_str))
925 .collect::<Vec<_>>()
926 .join("\n");
927 (!text.trim().is_empty()).then_some(text)
928 }
929 _ => None,
930 }
931}
932
933fn redact_import_text(text: &str) -> String {
934 text.split_whitespace()
935 .map(|word| {
936 let lower = word.to_ascii_lowercase();
937 if ["password=", "token=", "secret=", "api_key="]
938 .iter()
939 .any(|prefix| lower.starts_with(prefix))
940 {
941 "[REDACTED]"
942 } else {
943 word
944 }
945 })
946 .collect::<Vec<_>>()
947 .join(" ")
948}
949
950impl HistoryImportHostState {
951 pub fn from_current_host() -> Result<Self, HistoryImportError> {
953 let cwd = std::env::current_dir().map_err(|_| HistoryImportError::HostStateUnavailable)?;
954 let scope = agent_core::memory::store::ProjectScope::discover(&cwd)
955 .map_err(|_| HistoryImportError::HostStateUnavailable)?;
956 Ok(Self {
957 project_id: scope.key().to_owned(),
958 project_root: scope.root().to_path_buf(),
959 destination_r8_path: std::env::var_os("AXEL_BRAIN")
960 .map(PathBuf::from)
961 .or_else(|| {
962 std::env::var_os("SYNAPS_DATA_DIR")
963 .map(PathBuf::from)
964 .map(|dir| dir.join("axel.r8"))
965 })
966 .or_else(|| {
967 std::env::var_os("HOME")
968 .map(PathBuf::from)
969 .map(|home| home.join(".config/axel/axel.r8"))
970 })
971 .unwrap_or_else(|| scope.root().join("axel.r8")),
972 })
973 }
974}
975
976pub struct CanonicalHistoryMetadataIo {
979 sessions_dir: PathBuf,
980}
981
982impl CanonicalHistoryMetadataIo {
983 pub fn new() -> Self {
984 Self {
985 sessions_dir: agent_core::config::get_active_config_dir().join("sessions"),
986 }
987 }
988}
989
990impl Default for CanonicalHistoryMetadataIo {
991 fn default() -> Self {
992 Self::new()
993 }
994}
995
996impl HistoryImportIo for CanonicalHistoryMetadataIo {
997 fn scan_metadata(&mut self) -> Result<Vec<HistorySessionMetadata>, HistoryImportError> {
998 let entries = agent_core::session_journal::session_dir_entries(&self.sessions_dir)
999 .map_err(|_| HistoryImportError::MetadataScanFailed)?;
1000 Ok(entries
1001 .into_iter()
1002 .filter(|entry| entry.name.ends_with(".json"))
1003 .map(|entry| {
1004 let id = entry.name.trim_end_matches(".json").to_owned();
1005 let started_at = entry
1006 .mtime
1007 .map(chrono::DateTime::<chrono::Utc>::from)
1008 .map(|time| time.to_rfc3339())
1009 .unwrap_or_else(|| "unknown".to_string());
1010 HistorySessionMetadata {
1011 id,
1012 approx_bytes: entry.byte_len,
1013 started_at,
1014 }
1015 })
1016 .collect())
1017 }
1018
1019 fn read_session_content(&mut self, _id: &str) -> Result<(), HistoryImportError> {
1020 Err(HistoryImportError::ConsentRequired)
1021 }
1022
1023 fn write_axel(&mut self) -> Result<(), HistoryImportError> {
1024 Err(HistoryImportError::ConsentRequired)
1025 }
1026}
1027
1028pub fn preview_history_import(
1030 host: &HistoryImportHostState,
1031 io: &mut impl HistoryImportIo,
1032) -> Result<HistoryImportPreview, HistoryImportError> {
1033 let sessions = io.scan_metadata()?;
1034 let approx_bytes = sessions.iter().fold(0_u64, |total, session| {
1035 total.saturating_add(session.approx_bytes)
1036 });
1037 let included_date_range = sessions
1038 .iter()
1039 .map(|session| session.started_at.as_str())
1040 .min()
1041 .zip(
1042 sessions
1043 .iter()
1044 .map(|session| session.started_at.as_str())
1045 .max(),
1046 )
1047 .map(|(earliest, latest)| IncludedDateRange {
1048 earliest: earliest.to_owned(),
1049 latest: latest.to_owned(),
1050 });
1051
1052 Ok(HistoryImportPreview {
1053 project_id: host.project_id.clone(),
1054 project_root: host.project_root.clone(),
1055 session_count: sessions.len(),
1056 approx_bytes,
1057 included_date_range,
1058 included_content_classes: vec![
1059 "user_messages",
1060 "assistant_final_messages",
1061 "eligible_tool_outcome_summaries",
1062 "typed_terminal_outcomes",
1063 "session_turn_time_provenance",
1064 "compaction_linkage",
1065 ],
1066 excluded_content_classes: vec![
1067 "system_and_developer_prompts",
1068 "private_reasoning",
1069 "credentials_and_secret_prompt_responses",
1070 "raw_binary_attachments",
1071 "unbounded_tool_output",
1072 "never_persist_content",
1073 "foreign_project_content",
1074 ],
1075 retention_policy: "project-scoped local retention; never_persist content is excluded"
1076 .to_string(),
1077 redaction_policy:
1078 "host disclosure filtering, secret exclusion, and bounded tool-result summaries"
1079 .to_string(),
1080 destination_r8_path: host.destination_r8_path.clone(),
1081 explicit_confirmation_required: true,
1082 })
1083}
1084
1085pub fn authorize_history_import(
1087 preview: HistoryImportPreview,
1088 consent: HistoryImportConsent,
1089 authority: RequestAuthority,
1090 _io: &mut impl HistoryImportIo,
1091) -> Result<HistoryImportOutcome, HistoryImportError> {
1092 if authority == RequestAuthority::ModelTool {
1093 return Err(HistoryImportError::ModelCannotConfirm);
1094 }
1095
1096 match consent {
1097 HistoryImportConsent::Declined => Ok(HistoryImportOutcome::Declined),
1098 HistoryImportConsent::Confirmed(intent) => Ok(HistoryImportOutcome::Ready(ImportPlan {
1099 confirmation_id: intent.confirmation_id().to_owned(),
1100 user_intent: intent.proof,
1101 preview,
1102 })),
1103 }
1104}
1105
1106pub fn propose_history_import(
1108 host: &HistoryImportHostState,
1109 io: &mut impl HistoryImportIo,
1110) -> Result<HistoryImportPreview, HistoryImportError> {
1111 preview_history_import(host, io)
1112}
1113
1114pub fn index_history(
1116 host: &HistoryImportHostState,
1117 consent: HistoryImportConsent,
1118 authority: RequestAuthority,
1119 io: &mut impl HistoryImportIo,
1120) -> Result<HistoryImportOutcome, HistoryImportError> {
1121 if authority == RequestAuthority::ModelTool {
1123 return Err(HistoryImportError::ModelCannotConfirm);
1124 }
1125 let preview = preview_history_import(host, io)?;
1126 authorize_history_import(preview, consent, authority, io)
1127}
1128
1129#[cfg(test)]
1130mod tests {
1131 use super::*;
1132 use std::path::PathBuf;
1133
1134 #[derive(Default)]
1135 struct AccountingIo {
1136 metadata_scans: usize,
1137 content_reads: usize,
1138 axel_writes: usize,
1139 }
1140
1141 impl HistoryImportIo for AccountingIo {
1142 fn scan_metadata(&mut self) -> Result<Vec<HistorySessionMetadata>, HistoryImportError> {
1143 self.metadata_scans += 1;
1144 Ok(vec![
1145 HistorySessionMetadata {
1146 id: "session-a".into(),
1147 approx_bytes: 120,
1148 started_at: "2025-01-02T03:04:05Z".into(),
1149 },
1150 HistorySessionMetadata {
1151 id: "session-b".into(),
1152 approx_bytes: 80,
1153 started_at: "2025-02-03T04:05:06Z".into(),
1154 },
1155 ])
1156 }
1157
1158 fn read_session_content(&mut self, _id: &str) -> Result<(), HistoryImportError> {
1159 self.content_reads += 1;
1160 Ok(())
1161 }
1162
1163 fn write_axel(&mut self) -> Result<(), HistoryImportError> {
1164 self.axel_writes += 1;
1165 Ok(())
1166 }
1167 }
1168
1169 fn host() -> HistoryImportHostState {
1170 HistoryImportHostState {
1171 project_id: "project-host-owned".into(),
1172 project_root: PathBuf::from("/workspace/project"),
1173 destination_r8_path: PathBuf::from("/workspace/project/.r8/axel-memory"),
1174 }
1175 }
1176
1177 #[test]
1178 fn preview_fields_are_populated_from_host_state() {
1179 let mut io = AccountingIo::default();
1180 let preview = preview_history_import(&host(), &mut io).expect("preview");
1181
1182 assert_eq!(preview.project_id, "project-host-owned");
1183 assert_eq!(preview.project_root, PathBuf::from("/workspace/project"));
1184 assert_eq!(preview.session_count, 2);
1185 assert_eq!(preview.approx_bytes, 200);
1186 assert_eq!(
1187 preview.included_date_range,
1188 Some(IncludedDateRange {
1189 earliest: "2025-01-02T03:04:05Z".into(),
1190 latest: "2025-02-03T04:05:06Z".into(),
1191 })
1192 );
1193 assert!(!preview.included_content_classes.is_empty());
1194 assert!(!preview.excluded_content_classes.is_empty());
1195 assert!(!preview.retention_policy.is_empty());
1196 assert!(!preview.redaction_policy.is_empty());
1197 assert_eq!(
1198 preview.destination_r8_path,
1199 PathBuf::from("/workspace/project/.r8/axel-memory")
1200 );
1201 assert!(preview.explicit_confirmation_required);
1202 assert_eq!(io.metadata_scans, 1);
1203 assert_eq!(io.content_reads, 0);
1204 assert_eq!(io.axel_writes, 0);
1205 }
1206
1207 #[test]
1208 fn declined_consent_does_no_content_reads_or_axel_writes() {
1209 let mut io = AccountingIo::default();
1210 let preview = preview_history_import(&host(), &mut io).expect("preview");
1211 let outcome = authorize_history_import(
1212 preview,
1213 HistoryImportConsent::Declined,
1214 RequestAuthority::UserFrontend,
1215 &mut io,
1216 )
1217 .expect("decline is not an error");
1218
1219 assert_eq!(outcome, HistoryImportOutcome::Declined);
1220 assert_eq!(io.metadata_scans, 1);
1221 assert_eq!(io.content_reads, 0);
1222 assert_eq!(io.axel_writes, 0);
1223 }
1224
1225 #[test]
1226 fn model_self_confirmation_is_denied_before_any_read() {
1227 let mut io = AccountingIo::default();
1228 let result = index_history(
1229 &host(),
1230 HistoryImportConsent::Confirmed(ConfirmedUserIntent::for_test("confirmation-1")),
1231 RequestAuthority::ModelTool,
1232 &mut io,
1233 );
1234
1235 assert_eq!(result, Err(HistoryImportError::ModelCannotConfirm));
1236 assert_eq!(io.metadata_scans, 0);
1237 assert_eq!(io.content_reads, 0);
1238 assert_eq!(io.axel_writes, 0);
1239 }
1240
1241 #[test]
1242 fn model_proposal_returns_preview_but_has_no_confirmation_path() {
1243 let mut io = AccountingIo::default();
1244 let preview = propose_history_import(&host(), &mut io).expect("preview");
1245
1246 assert!(preview.explicit_confirmation_required);
1247 assert_eq!(io.metadata_scans, 1);
1248 assert_eq!(io.content_reads, 0);
1249 assert_eq!(io.axel_writes, 0);
1250 }
1251
1252 #[test]
1253 fn confirmed_user_consent_returns_typed_plan_without_starting_import() {
1254 let mut io = AccountingIo::default();
1255 let outcome = index_history(
1256 &host(),
1257 HistoryImportConsent::Confirmed(ConfirmedUserIntent::for_test("confirmation-2")),
1258 RequestAuthority::UserFrontend,
1259 &mut io,
1260 )
1261 .expect("confirmed plan");
1262
1263 let HistoryImportOutcome::Ready(plan) = outcome else {
1264 panic!("expected typed plan");
1265 };
1266 assert_eq!(plan.preview.session_count, 2);
1267 assert_eq!(plan.confirmation_id, "confirmation-2");
1268 assert_eq!(io.metadata_scans, 1);
1269 assert_eq!(io.content_reads, 0);
1270 assert_eq!(io.axel_writes, 0);
1271 }
1272
1273 mod streaming_import {
1274 use super::*;
1275 use crate::runtime::capture_worker::{CaptureFailure, CaptureProvider, CaptureWorker};
1276 use crate::runtime::chat_capture::ChatTurnCapture;
1277 use crate::runtime::memory_context::{
1278 mint_explicit_command_proof, CapturePolicy, ContextProviderId, MemoryContextLease,
1279 MemoryContextMode, MemoryLeaseId, ProjectId, RecallPolicy, SessionId,
1280 };
1281 use agent_core::session::Session;
1282 use agent_core::session_journal::{save_session_in_dir, SessionPersistence};
1283 use chrono::{TimeZone, Utc};
1284 use serde_json::json;
1285 use std::collections::HashSet;
1286 use std::sync::atomic::{AtomicUsize, Ordering};
1287 use std::sync::{Arc, Mutex};
1288 use std::time::{Duration, Instant, SystemTime};
1289
1290 struct RecordingProvider {
1291 captures: Mutex<Vec<ChatTurnCapture>>,
1292 network_constructions: std::sync::atomic::AtomicUsize,
1293 block: Mutex<Option<std::sync::mpsc::Receiver<()>>>,
1294 }
1295
1296 impl Default for RecordingProvider {
1297 fn default() -> Self {
1298 Self {
1299 captures: Mutex::new(Vec::new()),
1300 network_constructions: std::sync::atomic::AtomicUsize::new(0),
1301 block: Mutex::new(None),
1302 }
1303 }
1304 }
1305
1306 impl CaptureProvider for RecordingProvider {
1307 fn capture(&self, capture: ChatTurnCapture) -> Result<(), CaptureFailure> {
1308 self.captures.lock().expect("captures lock").push(capture);
1309 if let Some(receiver) = self.block.lock().expect("block lock").take() {
1310 let _ = receiver.recv();
1311 }
1312 Ok(())
1313 }
1314 }
1315
1316 fn fixture_session(id: &str, user: &str, assistant: &str) -> Session {
1317 let timestamp = Utc.timestamp_opt(1_700_000_000, 0).unwrap();
1318 Session {
1319 id: id.into(),
1320 title: id.into(),
1321 name: None,
1322 model: "fixture-model".into(),
1323 thinking_level: "brief".into(),
1324 system_prompt: Some("must never import".into()),
1325 created_at: timestamp,
1326 updated_at: timestamp,
1327 total_input_tokens: 0,
1328 total_output_tokens: 0,
1329 session_cost: 0.0,
1330 message_count: 0,
1331 api_messages: vec![
1332 Arc::new(json!({"role": "user", "content": user})),
1333 Arc::new(json!({"role": "assistant", "content": assistant})),
1334 ],
1335 abort_context: None,
1336 parent_session: None,
1337 compacted_into: None,
1338 env: None,
1339 env_stripped: Vec::new(),
1340 prompt_provenance: None,
1341 compaction: None,
1342 }
1343 }
1344
1345 fn append_index(dir: &std::path::Path, id: &str, root: &std::path::Path) {
1346 use std::io::Write;
1347 std::fs::create_dir_all(dir).unwrap();
1348 let mut index = std::fs::OpenOptions::new()
1349 .create(true)
1350 .append(true)
1351 .open(dir.join("index.jsonl"))
1352 .unwrap();
1353 writeln!(
1354 index,
1355 "{}",
1356 json!({
1357 "schema_version": 1,
1358 "session_id": id,
1359 "event": "start",
1360 "timestamp": "2023-11-14T22:13:20Z",
1361 "cwd": root,
1362 })
1363 )
1364 .unwrap();
1365 }
1366
1367 fn plan(root: &std::path::Path, session_count: usize) -> ImportPlan {
1368 ImportPlan {
1369 preview: HistoryImportPreview {
1370 project_id: "project-import".into(),
1371 project_root: root.to_path_buf(),
1372 session_count,
1373 approx_bytes: 1,
1374 included_date_range: None,
1375 included_content_classes: vec!["user_messages", "assistant_final_messages"],
1376 excluded_content_classes: vec!["foreign_project_content"],
1377 retention_policy: "standard".into(),
1378 redaction_policy: "host".into(),
1379 destination_r8_path: root.join("axel.r8"),
1380 explicit_confirmation_required: true,
1381 },
1382 confirmation_id: "confirmed-import".into(),
1383 user_intent: mint_explicit_command_proof(),
1384 }
1385 }
1386
1387 fn lease() -> MemoryContextLease {
1388 let now = SystemTime::now();
1389 MemoryContextLease::grant(
1390 MemoryLeaseId::parse("lease-import").unwrap(),
1391 SessionId::parse("active-session").unwrap(),
1392 ProjectId::parse("project-import").unwrap(),
1393 ContextProviderId::parse("extension:axel:memory").unwrap(),
1394 MemoryContextMode::CaptureOnly,
1395 CapturePolicy::default(),
1396 RecallPolicy::default(),
1397 mint_explicit_command_proof(),
1398 now,
1399 Some(now + Duration::from_secs(60)),
1400 )
1401 .unwrap()
1402 }
1403
1404 fn wait_for(provider: &RecordingProvider, count: usize) {
1405 for _ in 0..100 {
1406 if provider.captures.lock().unwrap().len() >= count {
1407 return;
1408 }
1409 std::thread::sleep(Duration::from_millis(5));
1410 }
1411 panic!("capture worker did not drain");
1412 }
1413
1414 #[test]
1415 fn legacy_json_and_journal_sessions_stream_through_one_host_api() {
1416 let temp = tempfile::TempDir::new().unwrap();
1417 let root = temp.path().join("project");
1418 let sessions = temp.path().join("sessions");
1419 std::fs::create_dir_all(&root).unwrap();
1420
1421 let legacy = fixture_session("legacy", "old user token=secret-sentinel", "old answer");
1422 save_session_in_dir(&sessions, &legacy, SessionPersistence::Json).unwrap();
1423 append_index(&sessions, "legacy", &root);
1424
1425 let mut journal = fixture_session("journal", "journal user", "journal answer");
1426 save_session_in_dir(&sessions, &journal, SessionPersistence::Journal).unwrap();
1427 journal.api_messages.push(Arc::new(json!({
1428 "role": "user", "content": "appended user"
1429 })));
1430 journal.api_messages.push(Arc::new(json!({
1431 "role": "assistant", "content": "appended answer"
1432 })));
1433 save_session_in_dir(&sessions, &journal, SessionPersistence::Journal).unwrap();
1434 append_index(&sessions, "journal", &root);
1435
1436 let provider = Arc::new(RecordingProvider::default());
1437 let worker = CaptureWorker::new(4);
1438 let report = import_history_from_dir(
1439 &plan(&root, 2),
1440 &lease(),
1441 &sessions,
1442 2,
1443 &worker,
1444 provider.clone(),
1445 )
1446 .unwrap();
1447
1448 assert_eq!(report.sessions_loaded, 2);
1449 assert_eq!(report.captures_built, 3);
1450 wait_for(&provider, 3);
1451 let captures = provider.captures.lock().unwrap();
1452 let users: Vec<&str> = captures
1453 .iter()
1454 .map(|c| c.user.content.text.as_str())
1455 .collect();
1456 assert!(users.contains(&"old user [REDACTED]"));
1457 assert!(users.contains(&"journal user"));
1458 assert!(users.contains(&"appended user"));
1459 assert!(captures.iter().all(|capture| !capture
1460 .user
1461 .content
1462 .text
1463 .contains("secret-sentinel")));
1464 assert_eq!(
1465 provider
1466 .network_constructions
1467 .load(std::sync::atomic::Ordering::SeqCst),
1468 0
1469 );
1470 }
1471
1472 #[test]
1473 fn foreign_project_session_is_not_opened_or_disclosed() {
1474 let temp = tempfile::TempDir::new().unwrap();
1475 let root = temp.path().join("project-a");
1476 let foreign_root = temp.path().join("project-b");
1477 let sessions = temp.path().join("sessions");
1478 std::fs::create_dir_all(&root).unwrap();
1479 std::fs::create_dir_all(&foreign_root).unwrap();
1480
1481 let own = fixture_session("own", "own user", "own answer");
1482 save_session_in_dir(&sessions, &own, SessionPersistence::Json).unwrap();
1483 append_index(&sessions, "own", &root);
1484 append_index(&sessions, "foreign-sentinel", &foreign_root);
1485 std::fs::write(
1486 sessions.join("foreign-sentinel.json"),
1487 b"SENTINEL: opening this foreign session must fail parsing",
1488 )
1489 .unwrap();
1490
1491 let provider = Arc::new(RecordingProvider::default());
1492 let worker = CaptureWorker::new(2);
1493 let report = import_history_from_dir(
1494 &plan(&root, 1),
1495 &lease(),
1496 &sessions,
1497 1,
1498 &worker,
1499 provider.clone(),
1500 )
1501 .unwrap();
1502
1503 assert_eq!(report.sessions_loaded, 1);
1504 wait_for(&provider, 1);
1505 let captures = provider.captures.lock().unwrap();
1506 assert_eq!(captures.len(), 1);
1507 assert_eq!(captures[0].session_id.as_str(), "own");
1508 }
1509
1510 #[test]
1511 fn import_batches_are_hard_bounded() {
1512 let temp = tempfile::TempDir::new().unwrap();
1513 let root = temp.path().join("project");
1514 let sessions = temp.path().join("sessions");
1515 std::fs::create_dir_all(&root).unwrap();
1516 let provider = Arc::new(RecordingProvider::default());
1517 let worker = CaptureWorker::new(1);
1518
1519 let error = import_history_from_dir(
1520 &plan(&root, 0),
1521 &lease(),
1522 &sessions,
1523 IMPORT_BATCH_MAX_RECORDS + 1,
1524 &worker,
1525 provider,
1526 )
1527 .unwrap_err();
1528
1529 assert_eq!(error, HistoryImportError::InvalidBatchSize);
1530 }
1531
1532 #[test]
1533 fn import_queue_overflow_is_bounded_and_reported() {
1534 let temp = tempfile::TempDir::new().unwrap();
1535 let root = temp.path().join("project");
1536 let sessions = temp.path().join("sessions");
1537 std::fs::create_dir_all(&root).unwrap();
1538 let session = fixture_session("many", "u1", "a1");
1539 save_session_in_dir(&sessions, &session, SessionPersistence::Json).unwrap();
1540 append_index(&sessions, "many", &root);
1541 let mut session = session;
1543 for (user, assistant) in [("u2", "a2"), ("u3", "a3")] {
1544 session
1545 .api_messages
1546 .push(Arc::new(json!({"role": "user", "content": user})));
1547 session
1548 .api_messages
1549 .push(Arc::new(json!({"role": "assistant", "content": assistant})));
1550 }
1551 save_session_in_dir(&sessions, &session, SessionPersistence::Json).unwrap();
1552
1553 let (release_tx, release_rx) = std::sync::mpsc::channel();
1554 let provider = Arc::new(RecordingProvider {
1555 block: Mutex::new(Some(release_rx)),
1556 ..RecordingProvider::default()
1557 });
1558 let worker = CaptureWorker::new(1);
1559 let error =
1560 import_history_from_dir(&plan(&root, 1), &lease(), &sessions, 3, &worker, provider)
1561 .unwrap_err();
1562 release_tx.send(()).unwrap();
1563
1564 assert_eq!(error, HistoryImportError::CaptureQueueFull);
1565 assert!(worker.overflow_drops() >= 1);
1566 }
1567
1568 struct DurableDigestProvider {
1569 digests: Mutex<HashSet<String>>,
1570 committed: AtomicUsize,
1571 cancel_after: Option<usize>,
1572 cancel: Option<HistoryImportCancellation>,
1573 }
1574
1575 impl CaptureProvider for DurableDigestProvider {
1576 fn capture(&self, capture: ChatTurnCapture) -> Result<(), CaptureFailure> {
1577 let inserted = self
1578 .digests
1579 .lock()
1580 .unwrap()
1581 .insert(capture.source_digest.to_hex());
1582 if inserted {
1583 let committed = self.committed.fetch_add(1, Ordering::SeqCst) + 1;
1584 if self.cancel_after == Some(committed) {
1585 self.cancel.as_ref().unwrap().cancel();
1586 }
1587 }
1588 Ok(())
1589 }
1590
1591 fn contains_capture(
1592 &self,
1593 _capture_id: &[u8; 32],
1594 ) -> Result<crate::runtime::capture_worker::CaptureCommitState, CaptureFailure>
1595 {
1596 Ok(crate::runtime::capture_worker::CaptureCommitState::Absent)
1597 }
1598 }
1599
1600 fn many_turn_session(id: &str, turns: usize, sentinel: &str) -> Session {
1601 let mut session = fixture_session(id, &format!("{sentinel}-u1"), "a1");
1602 for turn in 2..=turns {
1603 session.api_messages.push(Arc::new(json!({
1604 "role": "user", "content": format!("{sentinel}-u{turn}")
1605 })));
1606 session.api_messages.push(Arc::new(json!({
1607 "role": "assistant", "content": format!("a{turn}")
1608 })));
1609 }
1610 session
1611 }
1612
1613 fn wait_until(timeout: Duration, predicate: impl Fn() -> bool) {
1614 let deadline = Instant::now() + timeout;
1615 while !predicate() && Instant::now() < deadline {
1616 std::thread::sleep(Duration::from_millis(2));
1617 }
1618 assert!(predicate());
1619 }
1620
1621 #[test]
1622 fn forced_stop_resumes_last_checkpoint_without_duplicate_source_digests() {
1623 let temp = tempfile::TempDir::new().unwrap();
1624 let root = temp.path().join("project");
1625 let sessions = temp.path().join("sessions");
1626 let checkpoints = temp.path().join("private/import-checkpoints.json");
1627 std::fs::create_dir_all(&root).unwrap();
1628 let session = many_turn_session("resume", 6, "CONTENT_SENTINEL");
1629 save_session_in_dir(&sessions, &session, SessionPersistence::Json).unwrap();
1630 append_index(&sessions, "resume", &root);
1631
1632 let cancel = HistoryImportCancellation::new();
1633 let first_provider = Arc::new(DurableDigestProvider {
1634 digests: Mutex::new(HashSet::new()),
1635 committed: AtomicUsize::new(0),
1636 cancel_after: Some(2),
1637 cancel: Some(cancel.clone()),
1638 });
1639 let worker = CaptureWorker::new(8);
1640 let mut first_events = Vec::new();
1641 let first = import_history_resumable_from_dir(
1642 &plan(&root, 1),
1643 &lease(),
1644 &sessions,
1645 &checkpoints,
1646 1,
1647 &worker,
1648 first_provider.clone(),
1649 &cancel,
1650 &mut |event| first_events.push(event),
1651 );
1652 assert_eq!(first, Err(HistoryImportError::Cancelled));
1653 wait_until(Duration::from_secs(1), || {
1654 first_provider.committed.load(Ordering::SeqCst) == 2
1655 });
1656
1657 let first_digests = first_provider.digests.lock().unwrap().clone();
1658 let resume_provider = Arc::new(DurableDigestProvider {
1659 digests: Mutex::new(first_digests.clone()),
1660 committed: AtomicUsize::new(0),
1661 cancel_after: None,
1662 cancel: None,
1663 });
1664 let resume_worker = CaptureWorker::new(8);
1665 let mut resume_events = Vec::new();
1666 let report = import_history_resumable_from_dir(
1667 &plan(&root, 1),
1668 &lease(),
1669 &sessions,
1670 &checkpoints,
1671 1,
1672 &resume_worker,
1673 resume_provider.clone(),
1674 &HistoryImportCancellation::new(),
1675 &mut |event| resume_events.push(event),
1676 )
1677 .unwrap();
1678 wait_until(Duration::from_secs(1), || {
1679 resume_provider.digests.lock().unwrap().len() == 6
1680 });
1681
1682 let final_digests = resume_provider.digests.lock().unwrap();
1683 assert_eq!(final_digests.len(), 6, "source ranges must be unique");
1684 assert_eq!(report.ranges_skipped, 1, "resume starts at last checkpoint");
1685 assert!(std::fs::metadata(&checkpoints).unwrap().len() <= CHECKPOINT_FILE_MAX_BYTES);
1686 #[cfg(unix)]
1687 assert_eq!(
1688 std::os::unix::fs::PermissionsExt::mode(
1689 &std::fs::metadata(&checkpoints).unwrap().permissions()
1690 ) & 0o777,
1691 0o600
1692 );
1693 }
1694
1695 #[test]
1696 fn cancellation_keeps_checkpoint_consistent_and_does_not_block_producer() {
1697 let temp = tempfile::TempDir::new().unwrap();
1698 let root = temp.path().join("project");
1699 let sessions = temp.path().join("sessions");
1700 let checkpoints = temp.path().join("private/import-checkpoints.json");
1701 std::fs::create_dir_all(&root).unwrap();
1702 let session = many_turn_session("cancel", 20, "CANCEL_SENTINEL");
1703 save_session_in_dir(&sessions, &session, SessionPersistence::Json).unwrap();
1704 append_index(&sessions, "cancel", &root);
1705
1706 let cancel = HistoryImportCancellation::new();
1707 cancel.cancel();
1708 let provider = Arc::new(RecordingProvider::default());
1709 let worker = CaptureWorker::new(1);
1710 let started = Instant::now();
1711 let result = import_history_resumable_from_dir(
1712 &plan(&root, 1),
1713 &lease(),
1714 &sessions,
1715 &checkpoints,
1716 1,
1717 &worker,
1718 provider,
1719 &cancel,
1720 &mut |_| {},
1721 );
1722
1723 assert_eq!(result, Err(HistoryImportError::Cancelled));
1724 assert!(started.elapsed() < Duration::from_millis(100));
1725 assert!(load_import_checkpoint_for_test(&checkpoints)
1726 .unwrap()
1727 .is_empty());
1728 }
1729
1730 #[test]
1731 fn progress_and_error_events_are_metadata_only() {
1732 let event = HistoryImportEvent::progress(3, 7, 1024, Duration::from_millis(9));
1733 let error = HistoryImportEvent::error(
1734 3,
1735 1024,
1736 Duration::from_millis(10),
1737 HistoryImportError::SessionLoadFailed,
1738 );
1739 for serialized in [
1740 serde_json::to_string(&event).unwrap(),
1741 serde_json::to_string(&error).unwrap(),
1742 ] {
1743 assert!(!serialized.contains("CONTENT_SENTINEL"));
1744 assert!(!serialized.contains("project"));
1745 assert!(!serialized.contains("path"));
1746 assert!(!serialized.contains("content"));
1747 }
1748 assert_eq!(event.name, "memory_import.progress");
1749 }
1750 }
1751}