Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/error_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class ErrorCode(StrEnum):
INVALID_SINCE_MODE = "INVALID_SINCE_MODE"
PARSE_ERROR = "PARSE_ERROR"
EXPORT_NOTHING_TO_EXPORT = "EXPORT_NOTHING_TO_EXPORT"
EXPORT_ALL_FAILED = "EXPORT_ALL_FAILED"
INTERNAL_ERROR = "INTERNAL_ERROR"


Expand Down
33 changes: 31 additions & 2 deletions api/export_api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Export endpoints -- bulk zip download and single-session md/json."""

import io
import json
import os
import zipfile
from datetime import datetime
Expand All @@ -12,7 +13,12 @@
from api.error_codes import ErrorCode, error_response
from models.export import ExportStateDict
from utils.exclusion_rules import is_session_excluded
from utils.export_engine import EXPORT_ERRORS as _EXPORT_ERRORS, ZipSink, run_bulk_export
from utils.export_engine import (
EXPORT_ERRORS as _EXPORT_ERRORS,
ExportFailure,
ZipSink,
run_bulk_export,
)
from utils.export_state_store import (
EXPORT_STATE_FILE,
atomic_write_export_state,
Expand Down Expand Up @@ -49,6 +55,17 @@ def _read_state() -> ExportStateDict:
return _load_state_from_disk()


def _serialize_export_failures(failures: list[ExportFailure]) -> list[dict[str, str]]:
return [
{
"session_id": item.session_id,
"code": str(item.code),
"message": item.message,
}
for item in failures
]


def _write_state(sessions_map: dict[str, float], count: int) -> None:
"""Persist merge of *sessions_map* and update last-export metadata (*count* = this run only)."""
with _state_lock():
Expand Down Expand Up @@ -123,8 +140,17 @@ def _on_export_error(sid: str, exc: Exception) -> None:
count = result.exported_session_count
new_sessions_map = result.new_sessions_map
latest_day = result.latest_day
failure_payload = _serialize_export_failures(result.failures)

if count == 0:
if result.failures:
return error_response(
ErrorCode.EXPORT_ALL_FAILED,
"All export candidates failed",
422,
since=since,
failures=failure_payload,
)
return error_response(
ErrorCode.EXPORT_NOTHING_TO_EXPORT,
"Nothing to export",
Expand All @@ -145,12 +171,15 @@ def _on_export_error(sid: str, exc: Exception) -> None:
suffix = "-incremental"
else:
suffix = ""
return send_file(
resp = send_file(
buf,
mimetype="application/zip",
as_attachment=True,
download_name=f"claude-code-export{suffix}-{date_tag}.zip", # type: ignore[call-arg]
)
if result.failures:
resp.headers["X-Export-Warnings"] = json.dumps(failure_payload)
return resp
Comment thread
coderabbitai[bot] marked this conversation as resolved.


@export_bp.route("/api/export/session/<path:project_name>/<session_id>")
Expand Down
12 changes: 11 additions & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Extra fields may appear for specific codes (for example `since` on invalid bulk-
| `INVALID_SINCE_MODE` | 400 | `POST /api/export` | `since` is not `all`, `last`, or `incremental` |
| `PARSE_ERROR` | 500 | Session, stats, export session | JSONL file could not be parsed |
| `EXPORT_NOTHING_TO_EXPORT` | 422 | `POST /api/export` | No sessions matched the requested slice |
| `EXPORT_ALL_FAILED` | 422 | `POST /api/export` | At least one session was attempted but every candidate failed |
| `INTERNAL_ERROR` | 500 | `GET .../stats`, export session | Unexpected failure after parse (e.g. stats computation) |

---
Expand Down Expand Up @@ -372,13 +373,22 @@ Filename pattern:

Zip contains Markdown per session and optional `manifest.jsonl` metadata.

When some sessions fail but at least one succeeds, the response is still **`200`** with the ZIP body (successful sessions only). Skipped sessions are listed in the response header:

| Header | When | Value |
|--------|------|--------|
| `X-Export-Warnings` | Partial export (≥1 success, ≥1 failure) | JSON array of `{ "session_id", "code", "message" }` |

`code` uses the same strings as the error catalog (`PARSE_ERROR`, `INTERNAL_ERROR`, etc.).

#### Errors

| Status | `code` | When | Extra fields |
|--------|--------|------|--------------|
| 400 | `INVALID_REQUEST_BODY` | Body is not a JSON object | — |
| 400 | `INVALID_SINCE_MODE` | Invalid `since` value | `since` echoes rejected value |
| 422 | `EXPORT_NOTHING_TO_EXPORT` | Zero sessions matched | `since` echoes request mode |
| 422 | `EXPORT_NOTHING_TO_EXPORT` | Zero sessions matched (none attempted) | `since` echoes request mode |
| 422 | `EXPORT_ALL_FAILED` | Candidates existed but every attempted session failed | `since`, `failures` (same shape as `X-Export-Warnings` entries) |

```bash
curl -X POST -H "Content-Type: application/json" \
Expand Down
67 changes: 67 additions & 0 deletions tests/test_export_api_bulk.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

from __future__ import annotations

import io
import json
import sys
import zipfile
from pathlib import Path

import pytest
Expand All @@ -14,6 +16,7 @@
from flask import Flask

from api.export_api import export_bp
from utils.jsonl_parser import parse_session


@pytest.fixture
Expand Down Expand Up @@ -69,6 +72,70 @@ def test_bulk_export_empty_returns_422_json(isolated_state, tmp_path):
assert body["since"] == "all"


def test_bulk_export_all_succeed_no_warnings_header(client):
resp = client.post("/api/export", json={"since": "all"})
assert resp.status_code == 200
assert resp.content_type.startswith("application/zip")
assert "X-Export-Warnings" not in resp.headers
zf = zipfile.ZipFile(io.BytesIO(resp.data))
md_files = [name for name in zf.namelist() if name.endswith(".md")]
assert len(md_files) == 2


def test_bulk_export_partial_fail_returns_warning_header(client, monkeypatch):
real_parse = parse_session

def flaky_parse(path: str):
if path.endswith("session_def456.jsonl"):
raise json.JSONDecodeError("bad", "doc", 0)
return real_parse(path)

monkeypatch.setattr("utils.export_engine.parse_session", flaky_parse)
resp = client.post("/api/export", json={"since": "all"})
assert resp.status_code == 200
assert "X-Export-Warnings" in resp.headers
warnings = json.loads(resp.headers["X-Export-Warnings"])
assert len(warnings) == 1
assert warnings[0]["session_id"] == "session_def456"
assert warnings[0]["code"] == "PARSE_ERROR"
zf = zipfile.ZipFile(io.BytesIO(resp.data))
assert len([name for name in zf.namelist() if name.endswith(".md")]) == 1


def test_bulk_export_all_fail_returns_422(client, monkeypatch):
def always_fail(path: str):
raise json.JSONDecodeError("bad", "doc", 0)

monkeypatch.setattr("utils.export_engine.parse_session", always_fail)
resp = client.post("/api/export", json={"since": "all"})
assert resp.status_code == 422
body = resp.get_json()
assert body["code"] == "EXPORT_ALL_FAILED"
assert body["since"] == "all"
assert len(body["failures"]) == 2
assert {item["code"] for item in body["failures"]} == {"PARSE_ERROR"}


def test_bulk_export_partial_fail_excludes_failed_from_state(
client, monkeypatch, export_state_file
):
real_parse = parse_session

def flaky_parse(path: str):
if path.endswith("session_def456.jsonl"):
raise json.JSONDecodeError("bad", "doc", 0)
return real_parse(path)

monkeypatch.setattr("utils.export_engine.parse_session", flaky_parse)
resp = client.post("/api/export", json={"since": "all"})
assert resp.status_code == 200

state = json.loads(export_state_file.read_text(encoding="utf-8"))
sessions = state.get("sessions", {})
assert "session_abc123" in sessions
assert "session_def456" not in sessions


def test_export_state_json_fields(isolated_state):
isolated_state.write_text(
json.dumps(
Expand Down
33 changes: 31 additions & 2 deletions utils/export_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from datetime import date, datetime, timezone
from typing import Any, Callable, Literal, Protocol

from api.error_codes import ErrorCode
Comment thread
clean6378-max-it marked this conversation as resolved.
Outdated
from models.project import ProjectDict, SessionListItemDict
from models.session import SessionDict, SessionMetadataDict
from models.stats import SessionStatsDict
Expand Down Expand Up @@ -59,6 +60,26 @@ def serialize_manifest_jsonl(manifest: list[dict[str, Any]]) -> str:
return "\n".join(json.dumps(e, default=str) for e in manifest) + "\n"


@dataclass
class ExportFailure:
"""One per-session bulk export failure for API warning/error payloads."""

session_id: str
message: str
code: ErrorCode


def failure_code_for_exception(exc: Exception, *, phase: str = "parse") -> ErrorCode:
"""Map an export exception to a stable :class:`ErrorCode`."""
if phase == "export":
return ErrorCode.INTERNAL_ERROR
if isinstance(exc, json.JSONDecodeError):
return ErrorCode.PARSE_ERROR
if isinstance(exc, EXPORT_ERRORS):
return ErrorCode.PARSE_ERROR
return ErrorCode.INTERNAL_ERROR


@dataclass
class BulkExportResult:
"""Outcome of a bulk export run."""
Expand All @@ -69,6 +90,7 @@ class BulkExportResult:
new_sessions_map: dict[str, float] = field(default_factory=dict)
exported_session_count: int = 0
failure_count: int = 0
failures: list[ExportFailure] = field(default_factory=list)
skipped_count: int = 0
skipped_mtime_unchanged_count: int = 0
total_candidates: int = 0
Expand Down Expand Up @@ -262,8 +284,15 @@ def run_bulk_export(
result = BulkExportResult()
manifest: list[dict[str, Any]] = []

def _record_failure(sid: str, exc: Exception) -> None:
def _record_failure(sid: str, exc: Exception, *, phase: str = "parse") -> None:
result.failure_count += 1
result.failures.append(
ExportFailure(
session_id=sid,
message=str(exc),
code=failure_code_for_exception(exc, phase=phase),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
if on_export_error is not None:
on_export_error(sid, exc)

Expand All @@ -283,7 +312,7 @@ def _export_parsed(
result.new_sessions_map[sid] = float(sess_info.get("modified", 0))
result.exported_session_count += 1
except Exception as exc:
_record_failure(sid, exc)
_record_failure(sid, exc, phase="export")

if since == "last":
latest_day, rows, scan_total = collect_sessions_for_latest_activity_day(
Expand Down
Loading