Skip to content

feat(session): export full-fidelity session archives as tar.xz - #6056

Closed
h3c-hexin wants to merge 1 commit into
Hmbown:mainfrom
h3c-hexin:session-archive-export
Closed

feat(session): export full-fidelity session archives as tar.xz#6056
h3c-hexin wants to merge 1 commit into
Hmbown:mainfrom
h3c-hexin:session-archive-export

Conversation

@h3c-hexin

@h3c-hexin h3c-hexin commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Summary

/export markdown is lossy and share-facing; this adds the machine-facing counterpart: one command packs the complete durable session record into a compressed archive, from the CLI or through the library for embedding hosts.

  • session_export module: write_session_archive streams the complete SavedSession — standalone system prompt, every user/assistant message including thinking, tool_use and tool_result blocks, the branch journal, and hydrated approval receipts — into a tar archive together with the portable SessionImportContainer and the session's artifacts/ tree, compressed with xz. Output goes to a sibling temp file renamed into place, so a failed export never leaves a truncated archive at the destination.
  • Archive layout (format version 1): session.json (restore with /load for full fidelity), container.json (version-tolerant /resume import of the conversation transcript), artifacts/** (regular files only — symlinks are skipped so an export cannot read outside the session directory; members are size-bounded so a file that shrinks mid-export fails the export instead of silently shifting every following header), and manifest.json (format version, generator, timestamp, member index) written last.
  • CLI: codewhale sessions export <id> [--output <path>] [--compression 0-9] [--skip-artifacts] [--force], with unambiguous id-prefix fallback like resume. Bare codewhale sessions keeps its listing behavior (optional clap subcommand).
  • Dependency: liblzma (the maintained, API-compatible continuation of xz2) with vendored static liblzma — no system xz headers, hermetic on every release target.
  • Not sanitized, on purpose: this is the session owner's complete log; /export remains the redacted, share-facing format. The distinction is documented on the module.

Tests

  • forkguard_session_archive_export_roundtrips_full_context — system prompt, thinking, tool call, and tool result all round-trip; container.json imports through the exact SavedSession::import_foreign path /resume uses
  • forkguard_session_archive_includes_artifacts_and_respects_skip — artifact inclusion with exact bytes, skip behavior, and path-traversal id rejection
  • forkguard_session_archive_rejects_artifact_shorter_than_recorded_size — mid-export shrink fails instead of zero-padding
  • session_archive_replaces_existing_output_atomically, session_archive_rejects_out_of_range_compression_level

Verified on this branch: 5 module tests pass; cargo check clean for lib and bins; cargo fmt clean.

Port notes: adapted to current main — the test imports use codewhale_models directly, and the CLI wiring merged against the current Sessions { limit, search } dispatch without conflicts.

Credits

Original implementation by @asto18089 (co-authored).


Devin Review

`/export` markdown is lossy and share-facing; this adds the
machine-facing counterpart: one command packs the complete durable
session record into a compressed archive, from the CLI or through the
library for embedding hosts.

- `session_export` module: `write_session_archive` streams the complete
  `SavedSession` — standalone system prompt, every user/assistant
  message including thinking, tool_use and tool_result blocks, the
  branch journal, and hydrated approval receipts — into a tar archive
  together with the portable `SessionImportContainer` and the
  session's `artifacts/` tree, compressed with xz. Output goes to a
  sibling temp file and is renamed into place, so a failed export never
  leaves a truncated archive.
- Archive layout (format version 1): `session.json` (extract and
  restore with `/load` for full fidelity), `container.json`
  (version-tolerant `/resume` import of the conversation transcript),
  `artifacts/**` (regular files only — symlinks are skipped so an
  export cannot read outside the session directory; members are
  size-bounded so a file that shrinks mid-export fails the export
  instead of corrupting the archive), and `manifest.json` (format
  version, generator, timestamp, member index) written last.
- CLI: `codewhale sessions export <id> [--output] [--compression 0-9]
  [--skip-artifacts] [--force]`, with unambiguous id-prefix fallback
  like resume; bare `codewhale sessions` keeps its listing behavior
  via an optional subcommand.
- Dependency: `liblzma` (the maintained, API-compatible continuation
  of `xz2`) with vendored static liblzma, so the export stays hermetic
  on every release target.
- Not sanitized, on purpose: this is the session owner's complete log;
  `/export` remains the redacted, share-facing format. The distinction
  is documented on the module.

Co-authored-by: asto18089 <44870036+asto18089@users.noreply.github.com>
Signed-off-by: asto18089 <asto18089@126.com>
Signed-off-by: pinvou3-dev <dev@pinvou3.local>
@h3c-hexin
h3c-hexin requested a review from Hmbown as a code owner September 11, 2026 07:36

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 9 potential issues.

Devin Review

Comment thread crates/tui/src/lib.rs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Read-only classification now includes export

telemetry_command_is_read_only classifies every Sessions variant as read-only. Export writes files, so reviewers must reassess this command-level classification.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread crates/tui/src/lib.rs
Comment on lines +7982 to +7987
if output_path.exists() && !force {
bail!(
"{} already exists; pass --force to overwrite it",
output_path.display()
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 No-force export overwrites concurrent files

With force false, a destination created after exists() is still replaced. write_session_archive publishes unconditionally, deleting the concurrent file.

Learn more

The no-force decision and archive publication are separate filesystem operations. The initial existence check can succeed, then another process can create the destination before the temporary archive is persisted at write_session_archive. The archive writer replaces existing destinations, as its replacement test confirms, so the later file is lost.

Example: Process A runs sessions export abc --output backup.tar.xz without --force and sees no destination. Process B creates backup.tar.xz before A finishes compression. Process A replaces B's file, although it was required to reject an existing destination.

Recommended fix: Carry the overwrite policy into write_session_archive and publish with an atomic no-clobber operation when force is false. Keep atomic replacement only for force == true; an additional preflight check can improve errors but cannot enforce the contract.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread crates/tui/src/lib.rs
Comment on lines +7972 to +7974
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
manager.load_session_by_prefix(id)?
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Prefix exports alter session history

A prefix fallback uses load_session_by_prefix, which applies resume-time tool-history repair. The archive can differ from the durable session selected by that prefix.

Learn more

Exact IDs use the non-repairing snapshot loader, but the prefix fallback reaches load_session_by_prefix. That method delegates to the resume loader, which repairs dangling, duplicate, and orphaned tool call/result pairs. Exporting the same session by exact ID and by prefix can therefore produce different session.json records.

Example: Session abc123 ends durably with an unfinished tool_use. sessions export abc123 preserves it, while sessions export abc repairs it before serialization. The second archive no longer represents the saved record.

Recommended fix: Add or reuse a prefix resolver that returns the unique full ID, then call load_session_snapshot with that ID. Keep load_session_by_prefix for resume callers that require repair.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +280 to +286
let child = entry.file_name().to_string_lossy().into_owned();
let member = if prefix.is_empty() {
format!("{ARTIFACTS_DIR_NAME}/{child}")
} else {
format!("{prefix}/{child}")
};
let path = dir.join(&child);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Non-Unicode artifacts abort export

An artifact with invalid UTF-8 gets a lossy child name. Rejoining that name targets a nonexistent file, so archive creation fails.

Learn more

Unix filenames are arbitrary bytes, but to_string_lossy replaces invalid byte sequences with Unicode replacement characters. Rejoining that display string does not recover the original directory entry. The later file open at append_member therefore returns NotFound and cancels the complete export.

Example: An artifact named with bytes report-\xFF.bin is collected as report-�.bin. The exporter tries to open the latter path, which does not name the original file.

Recommended fix: Preserve entry.path() or the original OsString as the source path. Define a collision-free archive-name encoding for non-Unicode path components instead of using the lossy display form.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +134 to +139
pub fn write_session_archive(
session: &SavedSession,
artifacts_dir: Option<&Path>,
output: &Path,
options: SessionArchiveOptions,
) -> io::Result<SessionArchiveSummary> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Archive API is externally unusable

Public write_session_archive requires SavedSession from a private module. Embedding hosts cannot name the parameter type, so they cannot call the API.

Learn more

Rust permits a public function to mention a type hidden behind a private module, but downstream crates cannot refer to that type in ordinary API use. session_export is public while session_manager remains private, and no public re-export exposes SavedSession. The CLI can call the function internally, but the advertised embedding-host surface cannot.

Example: A host can import codewhale_tui::session_export::write_session_archive, but it cannot import codewhale_tui::session_manager::SavedSession because that module is private. It therefore cannot provide the function's first argument.

Recommended fix: Expose the session type through a deliberate public API, or offer a public export entry point that accepts an accessible handle or session ID and performs loading internally. Audit associated public types so the entire signature is reachable downstream.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread crates/tui/src/lib.rs
Comment on lines +8004 to +8021
println!(
"Exported session {} ({}) to {}",
truncate_id(&session.metadata.id),
session.metadata.title,
summary.output.display()
);
println!(
" {} member(s), {} uncompressed -> {} archive",
summary.members.len(),
format_bytes(summary.total_member_bytes()),
format_bytes(summary.compressed_bytes())
);
if artifacts_dir.is_none() && !skip_artifacts {
println!(" (no artifacts directory found for this session)");
}
println!(
" Restore (full fidelity): /load <extracted session.json> inside the TUI; /resume imports the conversation only"
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Export output bypasses localization

The new success and restore messages are hard-coded English. TUI guidance requires user-visible prose through tr(locale, MessageId::...).

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +296 to +302
fn archive_header(size: u64) -> tar::Header {
let mut header = tar::Header::new_gnu();
header.set_size(size);
header.set_mode(0o644);
header.set_mtime(Utc::now().timestamp().max(0) as u64);
header.set_cksum();
header

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Header times defeat archive determinism

archive_header stamps each member with the current time. Identical inputs produce different archives despite deterministic member ordering.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +161 to +164
let temp = tempfile::Builder::new()
.prefix(".codewhale-session-export-")
.tempfile_in(&parent)?;
let temp_path = temp.into_temp_path();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Session archives can expose sensitive contents

The unsanitized archive inherits default file permissions. A permissive umask can make prompts, tool results, and approval receipts readable by other users.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +234 to +235
let dir = sessions_dir.join(session_id).join(ARTIFACTS_DIR_NAME);
dir.is_dir().then_some(dir)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Artifact-root symlink escapes session storage

A symlinked artifact root passes is_dir(). Export then archives regular files from its external target despite skipping nested symlink entries.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@Hmbown

Hmbown commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Thank you @h3c-hexin and @asto18089 for the session archive export! Adapted into the local 0.9.13 lane as 27a3320 with contributor authorship and co-author credit preserved. The harvest keeps durable snapshots for ID prefixes, adds atomic overwrite protection and confined artifact reads, and localizes the CLI receipts. Focused archive/CLI tests, localization, npm/web checks and the repository-configured Clippy gate pass. It will auto-close with credit when the final gated release head reaches main.

Hmbown pushed a commit that referenced this pull request Sep 11, 2026
Harvested from PR #6056 by @h3c-hexin

Preserve the durable session record, portable container, manifest and regular
artifacts in tar.xz. Resolve ID prefixes without resume-time history repair.
Publish atomically without clobbering unless --force is explicit, keep the
archive and Unix tar members owner-only, and reuse WorkspaceFile confinement
for artifact reads. Reject linked roots, hard links and nonportable names;
bound traversal and keep output outside the session store. Localize CLI
receipts in all 15 shipped packs and document unredacted content and restore
boundaries. This is a CLI surface, not a new embedding-host session API.

Validation: 15 focused archive/CLI/prefix tests passed; localization 51 passed,
0 failed. npm test: wrapper 66, SDK 9, web 446 passed. npm run check:web: 0
errors, 2 existing image warnings; version state 0.9.13 synchronized. TUI
Clippy --all-targets --all-features passed with the existing CI lint flags
(-D warnings and the repository's three existing exceptions). Format and
whitespace checks passed. APFS rejects invalid-UTF8 fixture creation; Linux
covers that case and macOS covers a real nonportable colon filename.

Co-authored-by: asto18089 <44870036+asto18089@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

Thanks @h3c-hexin — your contribution landed in 27a332003bcd on main:

feat(session): export confined full-fidelity archives (#6056)

Closing this PR now that the code is on main. Credit lives in the commit message and (where applicable) the CHANGELOG.md entry for the next release. Apologies for not closing this at the time of the merge — the auto-close workflow is new in v0.8.31.

If you want to land more work and would prefer your future PRs merge cleanly without a harvest step, the CONTRIBUTING.md doc has a short note on what makes a contribution mergeable as-is.

@github-actions github-actions Bot closed this Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants