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