Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
58 changes: 37 additions & 21 deletions claude_code_log/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1990,28 +1990,44 @@ def convert_jsonl_to(
# Fallback: old logic for single file mode or no cache.
#
# is_outdated() only compares the embedded tool version, not the
# source's freshness, so a single .jsonl that grows between runs
# (e.g. an in-progress session re-exported with the same tool
# version) would be wrongly skipped as "current" and serve stale
# HTML (issue #221 follow-up). Mirror the directory path's
# source-tracking intent with an mtime check: regenerate when the
# source file is newer than the existing output. Scoped to a file
# source — a directory source has no single mtime and is handled
# by the cache branch / `cache_was_updated` above.
# source's freshness, so a source that grows between runs (e.g. an
# in-progress session re-exported with the same tool version) would
# be wrongly skipped as "current" and serve stale HTML (issues #221,
# #254). Mirror the cached directory path's source-tracking intent
# with an mtime check: regenerate when a source is newer than the
# existing output.
#
# The directory cache persists the *source* mtime in its DB to
# detect change; single-file mode has no DB, so the *output* file's
# own mtime is the natural, persistence-free basis. It is sufficient
# because the output is always written after its source is read, so
# `output.mtime >= source.mtime` holds post-write; a later append
# bumps the source past it. Both approaches share the same
# filesystem-mtime granularity limit (a sub-tick append racing the
# prior write resolves on the next run) — no worse than the cache.
source_is_newer = (
input_path.is_file()
and output_path.exists()
and input_path.stat().st_mtime > output_path.stat().st_mtime
)
# This branch is taken for a single file (which never has a cache)
# and for a directory run WITHOUT a cache (e.g. --no-cache); the
# cached directory path handles freshness via `is_html_stale`
# above and doesn't reach here. There's no DB tracking per-source
# mtimes, so the *output* file's own mtime is the natural,
# persistence-free basis:
# - single file → the source file's own mtime;
# - directory → the NEWEST source .jsonl in the directory
# (the directory analogue; non-recursive, like
# how directory mode discovers sessions).
# This is sufficient because the output is always written after its
# sources are read, so `output.mtime >= source.mtime` holds
# post-write; a later append bumps the source past it. It shares the
# cache's filesystem-mtime granularity limit (a sub-tick append
# racing the prior write resolves on the next run) and, for a
# directory, the same non-recursive scope — a subagent transcript
# under `<stem>/subagents/` growing without its top-level parent
# .jsonl being touched isn't caught (rare: the parent session
# records the spawning tool_use and usually grows too).
source_is_newer = False
if output_path.exists():
output_mtime = output_path.stat().st_mtime
if input_path.is_file():
source_is_newer = input_path.stat().st_mtime > output_mtime
elif input_path.is_dir():
source_mtimes = [
f.stat().st_mtime for f in input_path.glob("*.jsonl")
]
source_is_newer = bool(source_mtimes) and (
max(source_mtimes) > output_mtime
)
should_regenerate = (
force_regenerate
or renderer.is_outdated(output_path)
Expand Down
37 changes: 37 additions & 0 deletions test/test_output_explicit.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,5 +332,42 @@ def test_combined_current_but_session_regenerated_says_processed(
assert "Successfully processed" in r2.output


class TestDirectoryNoCacheStaleness:
"""#254: a ``--no-cache`` DIRECTORY run has no cache to track per-source
mtimes, so it must fall back to a directory-wide freshness check (newest
source ``.jsonl`` vs the combined output). Otherwise a grown session
serves stale ``combined_transcripts.html`` — the directory analogue of the
single-file #221 bug. The CACHED directory path (no ``--no-cache``) tracks
this via ``is_html_stale`` and is unaffected."""

def test_grown_session_regenerates_without_cache(self, tmp_path: Path):
proj = tmp_path / "proj"
proj.mkdir()
src = _write_jsonl(proj / "aaaaaaaa.jsonl", "FIRST_dir_marker")
runner = CliRunner()

r1 = runner.invoke(main, [str(proj), "--no-cache"])
assert r1.exit_code == 0, r1.output
combined = proj / "combined_transcripts.html"
assert combined.exists()
assert "FIRST_dir_marker" in combined.read_text(encoding="utf-8")
out_mtime = combined.stat().st_mtime

# The session progresses; force the source strictly newer than the
# output so the check is robust on coarse-granularity filesystems.
with open(src, "a", encoding="utf-8") as f:
f.write(json.dumps(_user_entry("SECOND_dir_marker")) + "\n")
os.utime(src, (out_mtime + 2, out_mtime + 2))

r2 = runner.invoke(main, [str(proj), "--no-cache"])
assert r2.exit_code == 0, r2.output
assert "skipping regeneration" not in r2.output
second = combined.read_text(encoding="utf-8")
assert "SECOND_dir_marker" in second, (
"stale combined HTML served on a --no-cache directory re-run"
)
assert combined.stat().st_mtime > out_mtime


if __name__ == "__main__":
pytest.main([__file__, "-v"])
Loading