feat(session): export full-fidelity session archives as tar.xz - #6056
feat(session): export full-fidelity session archives as tar.xz#6056h3c-hexin wants to merge 1 commit into
Conversation
`/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>
There was a problem hiding this comment.
| if output_path.exists() && !force { | ||
| bail!( | ||
| "{} already exists; pass --force to overwrite it", | ||
| output_path.display() | ||
| ); | ||
| } |
There was a problem hiding this comment.
🔴 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
| Err(error) if error.kind() == std::io::ErrorKind::NotFound => { | ||
| manager.load_session_by_prefix(id)? | ||
| } |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
| 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); |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
| pub fn write_session_archive( | ||
| session: &SavedSession, | ||
| artifacts_dir: Option<&Path>, | ||
| output: &Path, | ||
| options: SessionArchiveOptions, | ||
| ) -> io::Result<SessionArchiveSummary> { |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
| 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" | ||
| ); |
| 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 |
| let temp = tempfile::Builder::new() | ||
| .prefix(".codewhale-session-export-") | ||
| .tempfile_in(&parent)?; | ||
| let temp_path = temp.into_temp_path(); |
There was a problem hiding this comment.
| let dir = sessions_dir.join(session_id).join(ARTIFACTS_DIR_NAME); | ||
| dir.is_dir().then_some(dir) |
|
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. |
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>
|
Thanks @h3c-hexin — your contribution landed in
Closing this PR now that the code is on If you want to land more work and would prefer your future PRs merge cleanly without a harvest step, the |
Summary
/exportmarkdown 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_exportmodule:write_session_archivestreams the completeSavedSession— 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 portableSessionImportContainerand the session'sartifacts/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.session.json(restore with/loadfor full fidelity),container.json(version-tolerant/resumeimport 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), andmanifest.json(format version, generator, timestamp, member index) written last.codewhale sessions export <id> [--output <path>] [--compression 0-9] [--skip-artifacts] [--force], with unambiguous id-prefix fallback like resume. Barecodewhale sessionskeeps its listing behavior (optional clap subcommand).liblzma(the maintained, API-compatible continuation ofxz2) with vendored static liblzma — no system xz headers, hermetic on every release target./exportremains 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.jsonimports through the exactSavedSession::import_foreignpath/resumeusesforkguard_session_archive_includes_artifacts_and_respects_skip— artifact inclusion with exact bytes, skip behavior, and path-traversal id rejectionforkguard_session_archive_rejects_artifact_shorter_than_recorded_size— mid-export shrink fails instead of zero-paddingsession_archive_replaces_existing_output_atomically,session_archive_rejects_out_of_range_compression_levelVerified on this branch: 5 module tests pass;
cargo checkclean for lib and bins;cargo fmtclean.Port notes: adapted to current main — the test imports use
codewhale_modelsdirectly, and the CLI wiring merged against the currentSessions { limit, search }dispatch without conflicts.Credits
Original implementation by @asto18089 (co-authored).