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
15pub 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 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(¤t.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 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
217pub(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
233pub 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 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 .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 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
444pub(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}