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