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