Skip to main content

agent_engine/runtime/
memory_history.rs

1//! Existing-history disclosure preview and consent gate (task D1).
2//!
3//! The host owns metadata discovery, disclosure policy, and authority. Both
4//! `/memory index-history` and the `memory_context(action=index_history)` tool
5//! enter through this engine; D2 will consume the typed [`ImportPlan`].
6//!
7//! This module deliberately contains no importer. A successful confirmation
8//! returns a plan and performs no session-content reads or Axel writes.
9
10use crate::runtime::memory_context::UserIntentProof;
11use std::path::PathBuf;
12
13/// Metadata that may be inspected before the user decides whether to import.
14/// It must not contain message, prompt, or tool-result bodies.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct HistorySessionMetadata {
17    pub id: String,
18    pub approx_bytes: u64,
19    /// RFC3339 timestamp supplied by the canonical session metadata API.
20    pub started_at: String,
21}
22
23/// Narrow I/O boundary for history import. D1 calls only `scan_metadata`;
24/// content reads and provider writes are reserved for D2 consuming a plan.
25pub 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/// Trusted, host-computed scope and destination. No field comes from model
32/// parameters or a provider response.
33#[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/// Complete disclosure presented before consent is requested.
47#[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    /// Stable frontend-neutral rendering of every mandatory disclosure field.
64    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/// Confirmation authority is represented by the same unforgeable A4 proof
100/// type used by memory leases. There is intentionally no string constructor.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct ConfirmedUserIntent {
103    proof: UserIntentProof,
104}
105
106impl ConfirmedUserIntent {
107    /// Host-only constructor for a slash-command confirmation. Frontends call
108    /// this only after presenting the preview; model JSON cannot construct a
109    /// `RequestId` or reach this crate-private gate.
110    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    /// Host-only constructor for an affirmative confirmation prompt.
119    #[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/// Identifies the caller, independently of any claimed consent payload.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum RequestAuthority {
155    UserFrontend,
156    ModelTool,
157}
158
159/// D2 input: an already disclosed preview bound to explicit user intent.
160#[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)] // public API kept stable; plans are short-lived consent tokens
169pub 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
210/// Hard ceiling on records accumulated before dispatch. The C3 worker queue is
211/// independently fixed-capacity and nonblocking; imports can therefore bound
212/// both their producer batch and consumer queue.
213pub 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
223/// Maximum durable host state for one project import. One digest is 64 bytes;
224/// this supports thousands of committed source ranges without unbounded growth.
225pub 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/// Content-free event envelope for frontends and diagnostics. The payload type
245/// intentionally has no content, session id, project id, or filesystem path.
246#[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
358/// D2 host-only import path. Session scope comes from the host session index;
359/// a foreign ID is rejected before its session artifact is opened. Session
360/// bodies then load exclusively through [`agent_core::session::Session`]'s
361/// canonical legacy+journal API and become C1-shaped, bounded captures sent
362/// through the existing C3 worker/provider seam.
363pub(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        // Axel stores durable capture receipts itself. Use the acknowledged
386        // resumable path; it never creates the legacy progress file in this mode.
387        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    // Scope first, content second: the index contains metadata only and lets
407    // us exclude foreign project sessions without probing their body files.
408    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    // Only enumerate names; full bodies are opened after the scope check and
435    // only by Session::load_from_dir (the canonical compatibility boundary).
436    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                _ => {} // system/developer/tool/raw content is excluded.
524            }
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/// Incremental D3 import. A source range is checkpointed only after the C3
535/// worker receives a provider acknowledgement (or idempotency reconciliation).
536/// Therefore a process killed between provider commit and checkpoint rename may
537/// replay at most one stable capture id, which the provider store deduplicates.
538#[allow(clippy::too_many_arguments)] // resumable import transaction dependencies remain explicit
539pub 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    // Axel's transaction receipt/tombstone is the only committed authority.
570    // A host progress file from another brain must never suppress import.
571    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                        // Cancellation can race with a provider acknowledgement.
754                        // Do not advance the host checkpoint after cancellation;
755                        // the stable C1 idempotency key makes this ambiguous range
756                        // safe for the existing C3/plugin store to see on resume.
757                        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    /// Resolve the same stable host-owned project scope used by memory tools.
953    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
977/// Production metadata provider over the canonical session storage API. It
978/// intentionally does not implement content reads or Axel writes in D1.
979pub 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
1029/// Compute the disclosure using only trusted host state and a metadata scan.
1030pub 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
1086/// Apply the user's decision without beginning import.
1087pub 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
1107/// Request authority is fixed by the entry point, not accepted as an argument.
1108pub fn propose_history_import(
1109    host: &HistoryImportHostState,
1110    io: &mut impl HistoryImportIo,
1111) -> Result<HistoryImportPreview, HistoryImportError> {
1112    preview_history_import(host, io)
1113}
1114
1115/// Single shared engine operation used by every frontend.
1116pub fn index_history(
1117    host: &HistoryImportHostState,
1118    consent: HistoryImportConsent,
1119    authority: RequestAuthority,
1120    io: &mut impl HistoryImportIo,
1121) -> Result<HistoryImportOutcome, HistoryImportError> {
1122    // Reject model self-confirmation before even the metadata scan.
1123    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            // Expand the legacy fixture to three completed turns.
1547            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}