Skip to content

feat(cli): non-zero exit codes on partial export failure#55

Merged
wpak-ai merged 4 commits into
masterfrom
feat/cli-export-exit-codes
May 27, 2026
Merged

feat(cli): non-zero exit codes on partial export failure#55
wpak-ai merged 4 commits into
masterfrom
feat/cli-export-exit-codes

Conversation

@clean6378-max-it
Copy link
Copy Markdown
Collaborator

@clean6378-max-it clean6378-max-it commented May 27, 2026

Closes #51

Summary

Adds scriptable exit codes to the CLI export subcommand so callers (CI, cron, monitoring) can detect partial data loss in bulk exports. Exit-code mapping lives in cmd_export() only; the shared export engine (run_bulk_export) continues to expose failure_count without calling sys.exit().

  • Exit 0 — all attempted sessions exported successfully, or nothing to export with no errors
  • Exit 1 — total failure (zero exported, one or more per-session errors)
  • Exit 2 — partial failure (some exported, some failed)
  • Stderr summary: Exported N of M sessions (K failed) when any session was attempted
  • Documented in scripts/export.py module docstring (--help epilog) and README CLI Export section

Depends on PR1 (feat/export-engine) for BulkExportResult.failure_count.

Changes

File Change
scripts/export.py _exit_bulk_export() wrapper; invoked after successful write and on empty-export path
README.md Exit code table under CLI Export
tests/test_cli_export_exit_codes.py Clean export (subprocess); partial/total failure (in-process with parse_session monkeypatch)

Behavior notes

  • Per-session failures still use catch-and-continue (batch semantics unchanged).
  • HTTP API unchanged (JSON error responses, not process exit codes).
  • Empty export with no failures still prints "Nothing to export." and exits 0.
  • Invalid JSONL lines that parse to "Untitled Session" are skipped, not counted as failures (existing parser behavior).

Test plan

  • pytest tests/test_cli_export_exit_codes.py tests/test_cli_e2e.py -v
  • pytest tests/test_export_engine_parity.py tests/test_export_api_bulk.py -q
  • Manual: python scripts/export.py export --base-dir <seed> --no-zip --out ./out$LASTEXITCODE 0
  • Manual: induce parse failure on one of two sessions → exit 2 and stderr summary

Summary by CodeRabbit

  • New Features

    • Export command now uses standardized exit codes (0 = success/no-op, 1 = total failure, 2 = partial failure) and consistently emits an “Exported N of M sessions (K failed)” summary; summary appears on stdout for clean success and on stderr when any failures occur.
  • Documentation

    • Added README section documenting the export subcommand exit codes and summary behavior.
  • Tests

    • Expanded coverage for exit-code scenarios, including an incremental noop case (exits 0, prints “Nothing to export.” to stdout, no stderr summary) and other success/partial/total failure cases; added test helper for isolated runs.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 27, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 548ab708-a2a2-42de-ae6e-81b18e0e5b31

📥 Commits

Reviewing files that changed from the base of the PR and between 719fb9a and fa8564b.

📒 Files selected for processing (2)
  • scripts/export.py
  • tests/test_cli_export_exit_codes.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/test_cli_export_exit_codes.py
  • scripts/export.py

📝 Walkthrough

Walkthrough

The PR adds explicit process exit codes (0 success, 1 total failure, 2 partial failure) to the export CLI via a new _exit_bulk_export() helper that prints a consolidated export summary and maps BulkExportResult counts to sys.exit() codes; README and tests updated to cover clean, partial, total, and incremental-noop scenarios.

Changes

Export CLI Exit Codes

Layer / File(s) Summary
Exit code contract and documentation
README.md, scripts/export.py
The three exit code semantics (0 success/no-op, 1 total failure, 2 partial failure) are documented in README and the module docstring.
Exit code mapping and helper
scripts/export.py
BulkExportResult is imported; _exit_bulk_export(result) is added to emit Exported N of M sessions (K failed) (stdout when K==0, stderr when K>0) and to call `sys.exit(1
cmd_export integration
scripts/export.py
cmd_export() now calls _exit_bulk_export(export_result) at former early-return paths (--since last, nothing-to-export branches) and unconditionally after saving export state so the summary and exit codes are always applied.
Exit code test coverage
tests/test_cli_export_exit_codes.py
Tests and helpers added: isolated HOME env for subprocess CLI runs, regex for the stderr summary, and tests validating clean export (exit 0), partial failure (exit 2), total failure (exit 1), since="last" early-return behavior, and incremental noop that suppresses the stderr summary on the second run.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • timon0305
  • wpak-ai

Poem

🐰 I hopped through code with a tiny cheer,
Exit zeros for wins, ones when drear,
Twos for partial triumphs on the trail,
A rabbit's short rhyme for a clearer fail,
🥕 Exported and counted — hooray, not vague or pale.

🚥 Pre-merge checks | ✅ 4 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR implements core requirements from #51: exit codes 0/1/2 mapping, stderr summary format, per-session error handling preserved, and documentation added. However, critical issues identified by review remain unresolved: misleading 'M' count (includes skipped sessions), test assertion bug (mocked exit without actual SystemExit verification), and missing incremental mode edge-case handling. Address semantic bug in summary (compute attempted = exported + failed, not total_candidates); fix test to assert real SystemExit with code 1; verify incremental mode prints 'Nothing to export' not summary; use elif for mutual-exclusion clarity; add docstring comments about NoReturn branches.
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(cli): non-zero exit codes on partial export failure' accurately and specifically describes the main change—implementing exit code semantics for the export CLI to signal partial/total failure.
Out of Scope Changes check ✅ Passed All changes are within scope: exit code mapping in CLI wrapper, summary printing, documentation updates, and test coverage—no unrelated refactoring or API changes introduced.
Description check ✅ Passed PR objectives document is comprehensive and detailed, explaining the feature, acknowledging review feedback on semantic/test bugs, and providing commit message guidance on required fixes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cli-export-exit-codes

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
scripts/export.py (1)

479-493: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

--since last early returns bypass failure exit codes.

On Line 482 and Line 492, cmd_export() returns before _exit_bulk_export(export_result) runs. If failures were recorded during bulk export in this mode, the command exits 0 instead of 1/2.

💡 Suggested fix
     if since == "last":
         if latest_day is None:
             print("Nothing to export (no qualifying sessions in scope).")
+            _exit_bulk_export(export_result)
             return
         print(
             f"Latest activity end-date (UTC): {latest_day.isoformat()} — "
             "exporting sessions that overlap that calendar day."
         )
         if export_result.latest_day_match_count == 0:
             print(
                 f"No sessions overlap {latest_day.isoformat()} (UTC); "
                 "nothing to export."
             )
+            _exit_bulk_export(export_result)
             return
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/export.py` around lines 479 - 493, The early returns inside
cmd_export when since == "last" skip calling _exit_bulk_export(export_result),
causing success exit codes even if export_result recorded failures; replace the
plain returns after the print statements (the branches that handle latest_day is
None and export_result.latest_day_match_count == 0) with a call to
_exit_bulk_export(export_result) so the process exits using the proper failure
code, retaining the printed messages; ensure you reference cmd_export,
latest_day, export_result, and _exit_bulk_export when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@scripts/export.py`:
- Around line 479-493: The early returns inside cmd_export when since == "last"
skip calling _exit_bulk_export(export_result), causing success exit codes even
if export_result recorded failures; replace the plain returns after the print
statements (the branches that handle latest_day is None and
export_result.latest_day_match_count == 0) with a call to
_exit_bulk_export(export_result) so the process exits using the proper failure
code, retaining the printed messages; ensure you reference cmd_export,
latest_day, export_result, and _exit_bulk_export when making the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 633a09ec-15fc-4e4e-930a-15504692dffc

📥 Commits

Reviewing files that changed from the base of the PR and between 5e4df43 and 3ab5ac1.

📒 Files selected for processing (3)
  • README.md
  • scripts/export.py
  • tests/test_cli_export_exit_codes.py

Call _exit_bulk_export(export_result) when cmd_export returns early for
latest_day is None or zero overlap, matching the empty-export path so
recorded failures map to exit 1/2 instead of always exiting 0.
Comment thread scripts/export.py
Comment thread tests/test_cli_export_exit_codes.py
Comment thread tests/test_cli_export_exit_codes.py
Comment thread tests/test_cli_export_exit_codes.py Outdated
Only print the Exported N of M summary when exported_session_count or
failure_count is non-zero, so clean --since incremental cron runs with
unchanged mtimes stay silent on stderr. Add subprocess test for the
no-op path; tighten since-last early-return test to realistic 0/0 counts.
@clean6378-max-it
Copy link
Copy Markdown
Collaborator Author

@CodeRabbit, can you review this one more time in more depth?

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 27, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Fetch the PR diff
gh pr view 55 --repo cppalliance/claude-code-chat-browser --json files,body,title,commits

echo "=== DIFF ==="
gh pr diff 55 --repo cppalliance/claude-code-chat-browser

Length of output: 15395


🏁 Script executed:

#!/bin/bash
echo "=== scripts/export.py ==="
cat scripts/export.py

echo ""
echo "=== utils/export_engine.py ==="
cat utils/export_engine.py 2>/dev/null || echo "NOT FOUND"

echo ""
echo "=== tests/test_cli_e2e.py ==="
cat tests/test_cli_e2e.py 2>/dev/null || echo "NOT FOUND"

Length of output: 42461



@clean6378-max-it Here is a thorough review of the PR.


Deep Review: feat(cli): non-zero exit codes on partial export failure

1. 🐛 Semantic Bug — total_candidates ("M") is misleading in two modes

_exit_bulk_export prints Exported N of M sessions (K failed) where M = result.total_candidates. But total_candidates means different things depending on mode:

Mode total_candidates value
all every session on disk ✓ (correct)
incremental every session on disk, including mtime-skipped ones
last scan_total — every session scanned across all projects

incremental example: 10 sessions on disk, 8 unchanged (skipped), 2 new (both succeed):

Exported 2 of 10 sessions (0 failed)   ← implies 8 failures, but they were skipped

Expected: Exported 2 of 2 sessions (0 failed) (only counting attempted sessions).

last example: 20 sessions across all projects, 5 match the latest calendar day:

Exported 5 of 20 sessions (0 failed)   ← implies 15 were lost, but 15 were date-filtered out

The fix is to compute m as "attempted = total − skipped" instead of raw total_candidates:

def _exit_bulk_export(result: BulkExportResult) -> None:
    n = result.exported_session_count
    k = result.failure_count
    # For "last" mode: only latest-day matches were attempted
    if result.latest_day is not None:
        m = result.latest_day_match_count
    else:
        # "all"/"incremental": attempted = total minus explicitly skipped
        m = result.total_candidates - result.skipped_mtime_unchanged_count
    if n > 0 or k > 0:
        print(f"Exported {n} of {m} sessions ({k} failed)", file=sys.stderr)
    if n == 0 and k > 0:
        sys.exit(1)
    elif k > 0:
        sys.exit(2)

2. 🔶 Code Clarity — Use elif in _exit_bulk_export

# current
if n == 0 and k > 0:
    sys.exit(1)
if k > 0:          # only reachable when n > 0, but reads as independent check
    sys.exit(2)

sys.exit() raises SystemExit so the second if is never reached when the first fires, but a reader has to know that. Use elif:

if n == 0 and k > 0:
    sys.exit(1)
elif k > 0:
    sys.exit(2)

3. 🔶 Type Annotation — -> None is wrong for exit paths

_exit_bulk_export has two branches that never return. The annotation should communicate this:

from typing import NoReturn

def _exit_bulk_export(result: BulkExportResult) -> None:
    ...
    if n == 0 and k > 0:
        sys.exit(1)   # these branches are NoReturn
    elif k > 0:
        sys.exit(2)

Since the function may return (when k == 0), the overall signature stays -> None, but you could add an overloaded or split helper if you want to be precise. At minimum, a comment noting the exit paths would help type checkers and readers.


4. 🐛 Test Gap — test_since_last_early_return_invokes_exit_bulk_export doesn't validate exit code 1

The docstring says "exits with code 1 when failure_count>0", but:

fake_result = BulkExportResult(latest_day=None)  # failure_count=0 (default)
monkeypatch.setattr(export, "_exit_bulk_export", _track_exit)  # mocked out entirely

_track_exit never calls sys.exit(), so no SystemExit is raised. The test only checks len(exit_calls) == 1 — it verifies _exit_bulk_export is called, but not that it would produce exit code 1. To actually test exit code 1 from an early-return:

fake_result = BulkExportResult(latest_day=None, failure_count=1, total_candidates=1)
# Don't monkeypatch _exit_bulk_export; instead catch SystemExit
with pytest.raises(SystemExit) as exc:
    export.cmd_export(args)
assert exc.value.code == 1

5. 🔶 Test Fragility — next(base.iterdir()) depends on directory iteration order

In test_cli_export_partial_failure_exits_two:

project_dir = next(base.iterdir())

base = tmp_path, which at that point contains only test-project/, so this works today. But if any earlier test fixture or pytest plugin creates additional entries under tmp_path, this could pick up the wrong directory. Use an explicit path:

project_dir = base / "test-project"

6. 🔶 Silent unhandled exception in --session path

if session_filter:
    filepath = _find_session(session_filter, base_dir)
    if not filepath:
        _die(f"Session not found: {session_filter}")
    session = parse_session(filepath)   # can raise; no try/except
    stats = compute_stats(session)
    _export_single(session, stats, fmt, out_dir)
    return

A corrupt JSONL for a targeted session produces an uncaught traceback instead of a user-friendly error message. Consider wrapping with:

try:
    session = parse_session(filepath)
    stats = compute_stats(session)
    _export_single(session, stats, fmt, out_dir)
except Exception as exc:
    _die(f"Failed to export session {session_filter}: {exc}")

This is pre-existing but now more visible since bulk export has polished error handling.


7. 🟡 Noisy stderr on clean success

A successful full export now always prints to stderr:

Exported 5 of 5 sessions (0 failed)

Scripts that treat any stderr output as a warning will flag clean exports. POSIX convention is: stderr for errors/diagnostics, stdout for normal output. Consider printing the success summary to stdout and reserving stderr for the failure cases only, or gating the summary on k > 0:

if n > 0 or k > 0:
    out = sys.stderr if k > 0 else sys.stdout
    print(f"Exported {n} of {m} sessions ({k} failed)", file=out)

8. ℹ️ --session export is excluded from exit-code semantics (undocumented)

The --session single-export path returns early before _exit_bulk_export is ever called. This is arguably correct, but neither the docstring nor README mention it. A note in the module docstring would prevent confusion:

Exit codes apply to bulk export only (no --session flag).

Summary

# Severity Issue
1 🐛 Bug M in stderr summary wrong for incremental and last modes
4 🐛 Bug Test doesn't actually validate exit code 1 from early-return path
2 🔶 Clarity Nested if instead of elif in _exit_bulk_export
3 🔶 Typing -> None annotation on a function with sys.exit() branches
5 🔶 Fragile next(base.iterdir()) in test
6 🔶 UX No error handling in --session path
7 🟡 Minor Success summary prints to stderr (may surprise scripts)
8 ℹ️ Doc --session exclusion from exit codes is undocumented

The core logic of exit codes (0/1/2 mapping) and the state-before-exit ordering are correct. Issues 1 and 4 are the ones most worth addressing before merge.

…mary

- m = n + k (exported + failed) instead of total_candidates; avoids
  misleading counts for incremental no-ops and --since last date-filtered
  sessions
- Use elif so mutual exclusion of exit-1/exit-2 is explicit
- Route summary to stdout when k==0 (clean success), stderr when k>0;
  prevents cron monitors treating clean exports as failures
- Add test_since_last_early_return_exits_one_on_failure: validates
  actual exit code 1 without mocking _exit_bulk_export
- Use explicit base/test-project instead of next(base.iterdir())
- Add comment on sys.exit branches and --session doc note in docstring
@clean6378-max-it clean6378-max-it requested a review from wpak-ai May 27, 2026 21:32
@wpak-ai wpak-ai merged commit 75b8174 into master May 27, 2026
6 checks passed
@wpak-ai wpak-ai deleted the feat/cli-export-exit-codes branch May 27, 2026 23:15
@clean6378-max-it clean6378-max-it restored the feat/cli-export-exit-codes branch May 28, 2026 15:51
@coderabbitai coderabbitai Bot mentioned this pull request May 28, 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.

claude-code-chat-browser: Return non-zero CLI exit code on partial export failure

3 participants