Skip to main content

mj_controller/import/
native_import.rs

1use super::*;
2
3pub struct NativeImportRequest<'a> {
4    pub harness: HarnessKind,
5    pub harness_home: &'a Path,
6    pub native_session_id: &'a str,
7    pub source_path: &'a Path,
8    pub transcript: &'a ClaudeTranscript,
9    pub bundle_id: &'a str,
10    pub profile_id: Option<&'a str>,
11    pub title: Option<&'a str>,
12    pub archive_directory: &'a Path,
13}
14
15/// Build, verify, and install a local archive for one already-located,
16/// already-parsed native session, for any harness, then update the in-memory
17/// state. The caller saves `state` only after this returns successfully.
18pub fn import_native_session(
19    config: &Config,
20    state: &mut State,
21    request: NativeImportRequest<'_>,
22    control: Option<&ImportControl<'_>>,
23) -> Result<ImportedClaudeSession> {
24    let NativeImportRequest {
25        harness,
26        harness_home,
27        native_session_id,
28        source_path,
29        transcript,
30        bundle_id,
31        profile_id,
32        title,
33        archive_directory,
34    } = request;
35    let bundle = config
36        .bundles
37        .get(bundle_id)
38        .with_context(|| format!("unknown bundle {bundle_id:?}"))?;
39    let session_title_override = title.map(str::to_owned);
40    let title = match session_title_override.as_deref() {
41        Some(title) if !title.trim().is_empty() => title.to_owned(),
42        Some(_) => bail!("import title must not be empty"),
43        None => harness_session_title(&transcript.events).unwrap_or_else(|| {
44            format!(
45                "Imported {} session {native_session_id}",
46                harness.display_name()
47            )
48        }),
49    };
50    let targets = session_edit_targets(transcript, harness_home)?;
51    let raw_project = raw_project_import(config, &targets);
52    let repositories =
53        collect_local_repositories(bundle, &targets.git_roots, raw_project.is_none(), control)?;
54    let native_artifacts =
55        collect_import_native_artifacts(harness, harness_home, native_session_id, source_path)?;
56    if harness == HarnessKind::Muse {
57        // The preview may precede a user's confirmation by minutes. Never
58        // pair its old transcript with a newer native conversation.
59        if let Some(control) = control {
60            control.check_cancelled()?;
61        }
62        let current = read_native_transcript(harness, source_path)?;
63        ensure!(
64            current.cwd == transcript.cwd
65                && current.edited_paths == transcript.edited_paths
66                && serde_json::to_value(&current.events)?
67                    == serde_json::to_value(&transcript.events)?,
68            "native session changed after it was selected; select it again"
69        );
70        ensure!(
71            native_artifacts
72                == collect_import_native_artifacts(
73                    harness,
74                    harness_home,
75                    native_session_id,
76                    source_path
77                )?,
78            "native session changed while being imported; stop its harness and retry"
79        );
80    }
81    let session_id = new_session_id()?;
82    let canonical_session =
83        canonical_import_session(session_id.as_str(), &transcript.events, source_path)?;
84    let timestamp = timestamp();
85    let profile_id = import_profile_id(config, profile_id, harness, harness_home)?;
86    let target_id = default_import_target_id(config);
87    let archive_path = archive_directory.join(format!("{session_id}.hel.zip"));
88    if let Some(control) = control {
89        control.report(ImportArchiveProgress::WritingArchive)?;
90    }
91    let verified = write_archive_atomic(
92        &archive_path,
93        &ArchiveInput {
94            session: mj_checkpoint::archive::SessionManifest {
95                id: session_id.clone(),
96                title: title.clone(),
97                harness_kind: harness,
98                profile_id: profile_id.clone(),
99                native_session_id: native_session_id.to_owned(),
100                created_at: timestamp.clone(),
101                checkpointed_at: timestamp.clone(),
102                hel_version: env!("CARGO_PKG_VERSION").into(),
103                relay_version: env!("CARGO_PKG_VERSION").into(),
104                adapter_version: "acp-v1".into(),
105            },
106            target: TargetManifest {
107                template_id: target_id.clone(),
108                target_kind: "import".into(),
109                details: BTreeMap::from([("source".into(), format!("{}-import", harness.id()))]),
110            },
111            bundle: BundleManifest {
112                id: bundle_id.to_owned(),
113                primary_repository: bundle.primary_repo.clone(),
114            },
115            canonical_session,
116            native_artifacts,
117            repositories,
118        },
119    )?;
120    if let Some(control) = control
121        && let Err(error) = control.check_cancelled()
122    {
123        let _ = fs::remove_file(&archive_path);
124        return Err(error);
125    }
126    let checkpoint = CheckpointMetadata {
127        archive_path: archive_path.clone(),
128        sha256: verified.archive_sha256,
129        created_at: timestamp.clone(),
130        event_frontier: transcript.events.last().map_or(0, |event| event.seq),
131    };
132    state.sessions.insert(
133        session_id.clone(),
134        SessionRecord {
135            project: Some(crate::project_catalog::snapshot(
136                bundle,
137                &mj_core::targets::CancellableProcessExecutor::with_timeout(Duration::from_secs(
138                    15,
139                )),
140                raw_project.is_none(),
141            )?),
142            target_runtime: None,
143            launch_base: None,
144            launch_branch: None,
145            checkout: None,
146            publication: None,
147            build_cache: None,
148            subagents: None,
149            // An imported history is a new session: when it is resumed into a
150            // container it gets its own workspace, like any session created
151            // now.
152            container_workspace: Some(mj_core::targets::new_container_workspace(&session_id)?),
153            create_managed_worktree: None,
154            workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
155            archived: false,
156            container_cpus: None,
157            container_memory: None,
158            id: session_id.clone(),
159            title,
160            harness_kind: harness,
161            last_profile: profile_id,
162            bundle_id: bundle_id.to_owned(),
163            project_directory: raw_project.as_ref().map(|(directory, _)| directory.clone()),
164            managed_worktree: None,
165            review: None,
166            no_project_memory: false,
167            no_mailbox: false,
168            target_template_id: raw_project.map_or(target_id, |(_, raw_target_id)| raw_target_id),
169            resource_allocation: None,
170            additional_mounts: Vec::new(),
171            state: SessionState::Stopped,
172            target: None,
173            native_session_id: Some(native_session_id.to_owned()),
174            acp_session_title: None,
175            session_title_override,
176            created_at: timestamp.clone(),
177            updated_at: timestamp,
178            viewed_through_event_ordinal: 0,
179            draft_input: String::new(),
180            last_error: None,
181            last_checkpoint_error: None,
182            checkpoint: Some(checkpoint),
183        },
184    );
185    Ok(ImportedClaudeSession {
186        session_id,
187        native_session_id: native_session_id.to_owned(),
188        source_jsonl: source_path.to_path_buf(),
189        source_cwd: transcript.cwd.clone(),
190        bundle_id: bundle_id.to_owned(),
191        archive_path,
192    })
193}
194
195pub(super) fn default_import_target_id(config: &Config) -> String {
196    config
197        .targets
198        .get_key_value("podman")
199        .map(|(id, _)| id)
200        .or_else(|| {
201            config.targets.iter().find_map(|(id, target)| {
202                matches!(
203                    target,
204                    TargetTemplate::LocalPodman { .. }
205                        | TargetTemplate::LocalDocker { .. }
206                        | TargetTemplate::SshPodman { .. }
207                        | TargetTemplate::SshDocker { .. }
208                )
209                .then_some(id)
210            })
211        })
212        .or_else(|| config.targets.keys().next())
213        .cloned()
214        .unwrap_or_else(|| "import".into())
215}
216
217/// Target that hosts raw project sessions on this machine.
218pub(super) fn raw_import_target_id(config: &Config) -> Option<String> {
219    let local_bare = |template: &TargetTemplate| matches!(template, TargetTemplate::LocalBare);
220    config
221        .targets
222        .get_key_value("localhost")
223        .filter(|(_, template)| local_bare(template))
224        .map(|(id, _)| id.clone())
225        .or_else(|| {
226            config
227                .targets
228                .iter()
229                .find_map(|(id, template)| local_bare(template).then(|| id.clone()))
230        })
231}
232
233/// A session that only wrote to its own repository can keep working in that
234/// directory, so import it as a raw project session instead of a bundle
235/// session. `session_edit_targets` always records the cwd root, so a single
236/// durable root is that root.
237pub fn raw_project_import(
238    config: &Config,
239    targets: &SessionEditTargets,
240) -> Option<(PathBuf, String)> {
241    let [cwd_root] = targets.git_roots.as_slice() else {
242        return None;
243    };
244    Some((cwd_root.clone(), raw_import_target_id(config)?))
245}
246
247pub(super) fn collect_local_repositories(
248    bundle: &ProjectBundle,
249    detected_roots: &[PathBuf],
250    isolated: bool,
251    control: Option<&ImportControl<'_>>,
252) -> Result<Vec<mj_checkpoint::archive::RepositorySnapshot>> {
253    let detected = detected_roots
254        .iter()
255        .map(|root| Ok((root_identity(root)?, root.clone())))
256        .collect::<Result<BTreeMap<_, _>>>()?;
257    let repository_paths = bundle
258        .repositories
259        .iter()
260        .map(|repository| {
261            // A local source remains identified by its configured path even
262            // after its checkout gains a network origin. GitHub-origin
263            // detection is still used for configured network sources.
264            let path = if let Some(configured_path) = repository.local.as_ref() {
265                let configured_path =
266                    fs::canonicalize(configured_path).unwrap_or_else(|_| configured_path.clone());
267                detected_roots
268                    .iter()
269                    .find(|root| {
270                        fs::canonicalize(root).unwrap_or_else(|_| (*root).clone())
271                            == configured_path
272                    })
273                    .cloned()
274            } else {
275                let identity = configured_repository_identity(repository)?.with_context(|| {
276                    format!("repository {:?} has no usable source", repository.id)
277                })?;
278                detected.get(&identity).cloned()
279            };
280            let path = path.with_context(|| {
281                format!(
282                    "repository {:?} was not detected in the native session",
283                    repository.id
284                )
285            })?;
286            Ok((repository.id.clone(), path))
287        })
288        .collect::<Result<BTreeMap<_, _>>>()?;
289    let git = SystemGit;
290    let repository_count = bundle.repositories.len();
291    bundle
292        .repositories
293        // Indexed parallel iteration keeps repository and manifest order
294        // identical to the configured bundle.
295        .par_iter()
296        .enumerate()
297        .map(|(index, repository)| {
298            if let Some(control) = control {
299                control.report(ImportArchiveProgress::Repository {
300                    current: index + 1,
301                    total: repository_count,
302                    id: repository.id.clone(),
303                })?;
304            }
305            let path = repository_paths
306                .get(&repository.id)
307                .expect("repository paths cover the validated bundle")
308                .clone();
309            ensure!(
310                path.is_dir(),
311                "local repository {:?} is missing at {}",
312                repository.id,
313                path.display()
314            );
315            let source = isolated
316                .then(|| {
317                    resolve_repository(repository, &ProcessExecutor)
318                        .with_context(|| format!("resolve network source for {:?}", repository.id))
319                })
320                .transpose()?;
321            // Isolated imports restore the native session's committed work on
322            // top of the source's available network baseline. Raw imports
323            // continue to use the live local checkout, including repositories
324            // without any remote.
325            let history = if isolated {
326                GitHistoryMode::DeltaFrom(import_delta_base(
327                    &path,
328                    &source.as_ref().expect("isolated source resolved").fetch_url,
329                )?)
330            } else {
331                GitHistoryMode::NoBundle
332            };
333            let origin_override = if let Some(source) = &source {
334                Some(source.fetch_url.clone())
335            } else {
336                Some(path.to_string_lossy().into_owned())
337            };
338            let mut snapshot = collect_git_snapshot_with_progress(
339                &git,
340                &path,
341                &GitCollectionSpec {
342                    id: repository.id.clone(),
343                    relative_destination: repository.destination.clone(),
344                    history,
345                    origin_override,
346                },
347                control.is_none_or(|control| control.include_untracked),
348                &|progress| {
349                    let Some(control) = control else {
350                        return Ok(());
351                    };
352                    match progress {
353                        GitSnapshotProgress::UntrackedFile {
354                            current,
355                            total,
356                            path,
357                        } => control.report(ImportArchiveProgress::UntrackedFile {
358                            repository_id: repository.id.clone(),
359                            current,
360                            total,
361                            path,
362                        }),
363                    }
364                },
365            )
366            .with_context(|| format!("collect local repository {:?}", repository.id))?;
367            if let Some(source) = source {
368                snapshot.metadata.push_urls = source
369                    .push_urls
370                    .iter()
371                    .map(|url| mj_checkpoint::archive::redact_origin_credentials(url))
372                    .collect::<Result<Vec<_>>>()?;
373                snapshot.metadata.remote_workspace = true;
374            } else {
375                snapshot.metadata.push_urls.clear();
376            }
377            Ok(snapshot)
378        })
379        .collect()
380}
381
382pub(super) fn canonical_import_session(
383    session_id: &str,
384    events: &[SequencedEvent],
385    source_path: &Path,
386) -> Result<mj_checkpoint::archive::CanonicalSessionSnapshot> {
387    let mut events = events.to_vec();
388    finalize_import_event_times(&mut events, source_path)?;
389    let mut materialized =
390        mj_transcript::projection::imported_materialized_session(session_id, &events);
391    materialized.session_title = harness_session_title(&events);
392    if let Some(last_activity_at_ms) = events.iter().filter_map(|event| event.recorded_at_ms).max()
393    {
394        materialized.last_activity_at_ms = Some(
395            materialized
396                .last_activity_at_ms
397                .map_or(last_activity_at_ms, |current| {
398                    current.max(last_activity_at_ms)
399                }),
400        );
401    }
402    canonical_session_from_materialized(&materialized)
403}
404
405pub(super) fn default_profile(config: &Config, harness: HarnessKind, home: &Path) -> String {
406    let source = fs::canonicalize(home).unwrap_or_else(|_| home.to_path_buf());
407    config
408        .enabled_profiles()
409        .find(|(_, profile)| {
410            profile.kind == harness
411                && fs::canonicalize(&profile.home).unwrap_or_else(|_| profile.home.clone())
412                    == source
413        })
414        .or_else(|| {
415            config
416                .enabled_profiles()
417                .find(|(_, profile)| profile.kind == harness)
418        })
419        .map(|(id, _)| id.to_owned())
420        .unwrap_or_else(|| format!("{}-import", harness.id()))
421}
422
423pub(super) fn import_profile_id(
424    config: &Config,
425    requested: Option<&str>,
426    harness: HarnessKind,
427    home: &Path,
428) -> Result<String> {
429    let Some(requested) = requested else {
430        return Ok(default_profile(config, harness, home));
431    };
432    let profile = config
433        .profiles
434        .get(requested)
435        .with_context(|| format!("unknown import profile {requested:?}"))?;
436    ensure!(profile.enabled, "import profile {requested:?} is disabled");
437    ensure!(
438        profile.kind == harness,
439        "import profile {requested:?} does not use {harness:?}"
440    );
441    Ok(requested.to_owned())
442}
443
444/// The upstream revision an imported repository deltas from. A repository
445/// without remote-tracking refs cannot tell us which ancestry a newly
446/// provisioned clone has, and Hel never bundles full history, so it fails here.
447pub(super) fn import_delta_base(path: &Path, fetch_url: &str) -> Result<String> {
448    let remotes = git_optional_text(path, ["remote"])?.unwrap_or_default();
449    let upstream_ref = git_optional_text(
450        path,
451        [
452            "rev-parse",
453            "--symbolic-full-name",
454            "--verify",
455            "--quiet",
456            "@{upstream}",
457        ],
458    )?;
459    for remote in remotes.lines() {
460        let Some(url) = git_optional_text(path, ["remote", "get-url", remote])? else {
461            continue;
462        };
463        let same_source = url == fetch_url
464            || mj_core::state::ProjectSourceIdentity::git_remote(&url).is_some_and(|identity| {
465                Some(identity) == mj_core::state::ProjectSourceIdentity::git_remote(fetch_url)
466            });
467        if !same_source {
468            continue;
469        }
470        let prefix = format!("refs/remotes/{remote}/");
471        let revision = upstream_ref
472            .as_deref()
473            .filter(|reference| reference.starts_with(&prefix))
474            .map(str::to_owned)
475            .unwrap_or_else(|| format!("{prefix}HEAD"));
476        if let Some(base) =
477            git_optional_text(path, ["rev-parse", "--verify", "--quiet", &revision])?
478        {
479            return Ok(base);
480        }
481    }
482    bail!(
483        "repository {} has no remote-tracking refs to import against for its selected network source; fetch its remote first",
484        path.display()
485    )
486}
487
488pub(super) fn git_optional_text<const N: usize>(
489    cwd: &Path,
490    arguments: [&str; N],
491) -> Result<Option<String>> {
492    let output = Command::new("git")
493        .args(arguments)
494        .current_dir(cwd)
495        .output()
496        .with_context(|| format!("start git in {}", cwd.display()))?;
497    if !output.status.success() {
498        return Ok(None);
499    }
500    let text = String::from_utf8(output.stdout).context("decode Git output")?;
501    Ok((!text.trim().is_empty()).then(|| text.trim().to_owned()))
502}
503
504pub(super) fn timestamp() -> String {
505    Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
506}
507
508pub fn persist_imported_session_locally(session: &SessionRecord) -> Result<()> {
509    crate::database::save_session(session)?;
510    let checkpoint = session
511        .checkpoint
512        .as_ref()
513        .context("imported session has no checkpoint")?;
514    let canonical = mj_checkpoint::archive::verify_archive_streaming(&checkpoint.archive_path)?
515        .canonical_session;
516    let materialized = mj_transcript::projection::materialized_session_from_canonical(
517        session.id.clone(),
518        &canonical,
519    )?;
520    crate::database::save_materialized_session(&materialized)
521}