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
8 changes: 6 additions & 2 deletions docs/guarantees.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,8 @@
- `[plugins.sessions]` の `config_dirs` ごとに `ccreel list --json --limit 0 --since <days>d --config-dir <dir>` を呼び、全部をまとめて新しい順に `public/sessions/index.html` へ一覧を書く。各セッションの本文を `ccreel show <id> --json` から `public/sessions/<id>.html` に書く
- 本文のページには発言と応答のテキストを出し、応答の Markdown は生の HTML・`<script>` を出さずに描く。`ccreel resume <id>` の行を出す
- セッションの `issue` が一覧にある Issue を指せば、先頭の 0 を無視してその Issue のページへのリンクにする。`repo` が走査対象のリポならリポのページへリンクする。どちらも無ければリンクにしない
- `show` に失敗したセッションも本文のページは作り、`Could not read this session` を出す。`ccreel` が無いとき、またはいずれかの config dir で `list` が失敗したときは、警告を出してそこを飛ばし、生成は止めない
- `show` に失敗したセッションも本文のページは作り、`Could not read this session` を出す。一部の config dir で `list` が失敗したときは、警告を出してそこを飛ばし、生成は止めない
- すべての config dir で `list` が失敗したとき(`ccreel` が無いときを含む)は、`codeview build` が `error:` と config dir ごとの理由を出して非0で終わり、`public/` を作らない。`codeview publish --if-changed` も push せずに非0で終わる。`list` の1行に `id`・`repo`・`title`・`prompts`・`started`・`updated` のどれかが欠けていれば、その config dir の `list` の失敗として扱う。`list` が成功して0件のときは止めず、セッションのページを書かずに生成を終える
- トップの左メニューに `sessions` への入口と件数を出し、トップにセッションの題は出さない
- `ccreel`・`config_dirs`・`days`・`every_hours` 以外のキーや、型の誤りは `error:` を出して非0で終わる
- `fingerprint()` は最新の更新時刻を `every_hours` の枠に丸め、枠の中の更新では変わらない
Expand All @@ -159,7 +160,10 @@
| Issue(先頭の 0 を無視)とリポへのリンク | `test_links_to_issue_and_repo` |
| 対応する先が無ければリンクにしない | `test_unlinked_when_issue_or_repo_is_missing` |
| `show` に失敗したセッションのページ | `test_unreadable_session_still_gets_a_page` |
| `list` の失敗・`ccreel` が無いときは警告して続ける | `test_build_survives_one_failing_config_dir`、`test_build_survives_missing_ccreel` |
| 一部の config dir で `list` が失敗しても、警告を出して続ける | `test_build_survives_one_failing_config_dir` |
| すべての config dir で `list` が失敗したときは `error:` で止める(`ccreel` が無いとき、`list` の行に項目が欠けているときを含む) | `test_build_stops_without_ccreel`、`test_build_stops_when_every_list_fails`、`test_build_stops_when_a_list_row_lacks_a_field` |
| すべての config dir で `list` が失敗したとき、`publish --if-changed` は push しない | `test_publish_if_changed_does_not_push_when_list_fails` |
| `list` が成功して0件のときは止めない | `test_build_with_empty_list_is_not_an_error` |
| トップの入口と件数 | `test_top_menu_links_sessions_without_titles` |
| 設定の誤り | `test_rejects_bad_options` |
| 指紋は最新の更新時刻を `every_hours` の枠に丸めて動く | `test_fingerprint_moves_only_across_windows`、`test_fingerprint_follows_every_hours` |
Expand Down
57 changes: 43 additions & 14 deletions examples/plugins/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
days = 14

ccreel (https://github.com/yktsnet/ccreel) が要る。`list --json` と `show --json` を外部コマンドとして呼ぶ。
すべての config dir で `list` が読めなければ(ccreel が無いときを含む)、生成を始める前にエラーで止める。
設定は `[plugins.sessions]`:
ccreel ccreel のコマンド(既定 "ccreel")。定期実行の PATH に無いときはパスで渡す
config_dirs 読む config dir の列(既定 ["~/.claude"])
Expand Down Expand Up @@ -164,28 +165,55 @@ def _ts(value: str) -> int:
return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp())


# `list --json` の1行に要る項目。欠けた行は、その config dir の失敗として扱う
_LIST_FIELDS = ("id", "repo", "title", "prompts", "started", "updated")


def _parse_list(out: str) -> list[dict]:
rows = []
for line in out.splitlines():
if not line.strip():
continue
row = json.loads(line)
if not isinstance(row, dict):
raise ValueError("a line of `list --json` is not an object")
missing = [k for k in _LIST_FIELDS if k not in row]
if missing:
raise ValueError(f"`list --json` has no '{missing[0]}' in a line")
row["ts"] = _ts(row["updated"])
row["started_ts"] = _ts(row["started"])
rows.append(row)
return rows


def _sessions(ctx) -> list[dict]:
"""config dir をまとめた新しい順の一覧。ccreel が無い・失敗した config dir は警告して飛ばし、生成は止めない。"""
"""config dir をまとめた新しい順の一覧。

一部の config dir で失敗したときは警告して飛ばす。すべてで失敗したとき(ccreel が無いときを含む)は
PluginError で止める。定期実行の PATH に ccreel が無いまま、セッションの無いサイトを配信しないため。
"""
if "list" in _cache:
return _cache["list"]
opts = _opts(ctx)
rows: list[dict] = []
failures: list[tuple[str, str]] = []
for config_dir in opts["config_dirs"]:
found: list[dict] = []
try:
out = _ccreel(opts, config_dir, "list", "--json", "--limit", "0", "--since", f"{opts['days']}d")
for line in out.splitlines():
if line.strip():
row = json.loads(line)
row["ts"] = _ts(row["updated"])
row["started_ts"] = _ts(row["started"])
row["config_dir"] = config_dir
found.append(row)
except (OSError, subprocess.SubprocessError, ValueError, KeyError) as e:
detail = getattr(e, "stderr", None) or str(e)
print(f"warning: sessions: ccreel list failed for {config_dir}, skip: {detail.strip()}", file=sys.stderr)
found = _parse_list(out)
except (OSError, subprocess.SubprocessError, ValueError, AttributeError, TypeError) as e:
failures.append((config_dir, (getattr(e, "stderr", None) or str(e)).strip()))
continue
for row in found:
row["config_dir"] = config_dir
rows += found
if len(failures) == len(opts["config_dirs"]):
from codeview.plugins import PluginError

reasons = "".join(f"\n {d}: {detail}" for d, detail in failures)
raise PluginError(f"sessions: ccreel list failed for every config dir:{reasons}")
for config_dir, detail in failures:
print(f"warning: sessions: ccreel list failed for {config_dir}, skip: {detail}", file=sys.stderr)
rows.sort(key=lambda r: r["ts"], reverse=True)
_cache["list"] = rows
return rows
Expand Down Expand Up @@ -228,8 +256,9 @@ def _repo_slug(ctx, name: str) -> str | None:


def css(ctx) -> str:
# 本体が最初に呼ぶ口なので、設定の誤りをここで ConfigError にして、public/ に触れる前に止める
_opts(ctx)
# 本体が最初に呼ぶ口で、ここで上がった ConfigError / PluginError だけが public/ に触れる前の `error:` になる。
# 設定の誤りと、一覧が1本も読めないことをここで表に出す
_sessions(ctx)
return CSS


Expand Down
75 changes: 75 additions & 0 deletions issues/done/08_sessions-fail-loud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
## PR記録: fix: 例の拡張 sessions は、セッションを1本も読めなければ生成を止める
issue: 08 (08_sessions-fail-loud.md)
PR: https://github.com/yktsnet/codeview/pull/26

## 変更内容
- `_sessions()` が config dir ごとの失敗を集め、成功した config dir が1つも無ければ `PluginError` を上げる。メッセージは失敗した config dir ごとの理由を含む英文
- `list --json` の行に `id`・`repo`・`title`・`prompts`・`started`・`updated` のどれかが欠けていれば、その config dir の失敗として扱う(オブジェクトでない行も同じ)
- `css()` で `_sessions(ctx)` を呼び、生成前に `error:` で止める(`publish` は指紋の時点で同じ例外が上がり、push せずに非0で終わる)
- 一部の config dir だけの失敗は、今までどおり警告して飛ばす。`list` が0件は止めない
- モジュールと `_sessions()` の docstring を新しい振る舞いに合わせた

## 保証
- 全 config dir で `list` が失敗・`ccreel` が無い → `error:`・非0・`public/` 無し: `tests/test_example_sessions.py` の `test_build_stops_without_ccreel`、`test_build_stops_when_every_list_fails`
- 行の項目が欠ければ config dir の失敗: `test_build_stops_when_a_list_row_lacks_a_field`(6項目それぞれ)
- `publish --if-changed` は push しない: `test_publish_if_changed_does_not_push_when_list_fails`
- 0件は止めない: `test_build_with_empty_list_is_not_an_error`
- 一部の失敗は警告して続ける(維持): `test_build_survives_one_failing_config_dir`
- `docs/guarantees.md` §5 を上に合わせて更新

## 静的確認結果
- `nix develop -c pytest -q`: 81 passed。新しいテストは旧実装で失敗することを確かめた
- 本体(`codeview/`)は変更なし

## 確認者の判定
| 項目 | 判定 |
|---|---|
| `nix develop -c pytest -q` が通る | 合 |
| 新しいテストは `site` フィクスチャで生成器を別プロセスで呼び、偽の `ccreel` で確かめている | 合 |
| 保証(全 config dir 失敗で止まる/行の項目欠け/publish は push しない/0件は止めない/一部失敗は警告/台帳) | すべて合 |
| 範囲の外 | なし |

---

## 例の拡張 sessions は、セッションを1本も読めなければ生成を止める
id: 08
branch-slug: sessions-fail-loud
status: close
type: fix
対象: examples/plugins/sessions.py, tests/test_example_sessions.py, docs/guarantees.md
内容: 例の拡張 `examples/plugins/sessions.py` は、`ccreel` が無いときや、すべての config dir で `list` が失敗したときも、警告を1行出すだけで生成を続ける。定期実行の `PATH` に ccreel が無いと、`publish --if-changed` はセッションのページが無いサイトを黙って配信する。ccreel の `list --json` の形が変わって要る項目が欠けた場合も、同じ結果になる。すべての config dir で読めなかったときは生成を始める前に止め、前回の配信を残す。
対象外: 本体(`codeview/` 配下)の変更。ccreel 側で JSON の形を固定するテスト。`show` の失敗の扱い(今のまま、そのセッションだけ `Could not read this session`)。一部の config dir だけが失敗したときの扱い(今のまま、警告して飛ばす)
仮定: 「止める」は、`codeview build` が `error:` を出して非0で終わり、`public/` に触れないこととする。本体は拡張の `css()` の呼び出しで起きた `PluginError` / `ConfigError` だけを `error:` にして生成前に止めるので、一覧の取得を `css()` の時点で済ませる(設定の検査を `css()` で行っているのと同じ理由)。`publish` は指紋を取る時点で同じ例外が上がり、push せずに非0で終わる(本体を変えないので、こちらは `error:` ではなくトレースバックになってよい)。`list` が成功して0件を返すのは正常で、止めない
確認: `nix develop -c pytest -q` が通る。新しいテストは既存の `tests/test_example_sessions.py` と同じく、`site` フィクスチャで生成器を別プロセスで呼び、偽の `ccreel` で確かめる
人が見るもの: なし

---

### 保証
- 新たに宣言する保証(例の拡張 `examples/plugins/sessions.py`):
- `ccreel` が無いとき、またはすべての config dir で `list` が失敗したときは、`codeview build` が `error:` と失敗した理由(config dir と ccreel の標準エラーか例外の文)を出して非0で終わり、`public/` を作らない
- `list` の1行に `id`・`repo`・`title`・`prompts`・`started`・`updated` のどれかが欠けていれば、その config dir の `list` の失敗として扱う(すべての config dir がそうなら上と同じく止まる)
- 上の場合、`codeview publish --if-changed` は push せずに非0で終わる
- `list` が成功して0件のときは止めず、セッションのページを書かずに生成を終える(今の振る舞い)
- 変更する保証(台帳 §5):
- 「`ccreel` が無いとき、またはいずれかの config dir で `list` が失敗したときは、警告を出してそこを飛ばし、生成は止めない」を、「一部の config dir で `list` が失敗したときは、警告を出してそこを飛ばし、生成は止めない。すべての config dir で失敗したとき(`ccreel` が無いときを含む)は止める」に改める
- 維持する保証: 台帳 §5 のほかの項目すべて(`show` に失敗したセッションのページ、設定の誤りで `error:`、指紋、使い回し、相対リンク など)。台帳 §1 の「拡張を設定しなければ…」「読み込めない拡張を設定したときは…」。本体に触れないので、§1〜§4 のテストはそのまま通る

### examples/plugins/sessions.py

- `_sessions()` で、config dir ごとの失敗を集める。成功した config dir が1つも無ければ、`codeview.plugins.PluginError` を上げる。メッセージは英文で、失敗した config dir ごとの理由を含める(`ConfigError` / `PluginError` のメッセージは、そのまま利用者に出す英文にする規約)
- 行の検査: 上の6項目が無い行があれば、その config dir の失敗にする。今は `updated` と `started` の `KeyError` だけを拾っている
- `css()` で `_sessions(ctx)` を呼び、生成前に失敗を表に出す。`_cache` があるので、`side()` / `build()` / `fingerprint()` で二度は呼ばない
- モジュールと `_sessions()` の docstring の「生成は止めない」を、新しい振る舞いに合わせて直す

### tests/test_example_sessions.py

- `test_build_survives_missing_ccreel` は、止まることを確かめるテストに置き換える(`error:`・非0・`public/` が無い)
- すべての config dir で `list` が失敗する(不正な JSON・非0で終わる)場合と、要る項目が欠けた行の場合を足す
- `list` が0件のときに止まらないことを足す
- `publish --if-changed` が push しないことは、`tests/test_publish.py` の既存の作り(配信先のリモートと `site.run("-m", "codeview", "publish", "--if-changed")`)を参考に、このファイルの中で確かめる
- `test_build_survives_one_failing_config_dir` はそのまま残す

### docs/guarantees.md

§5 の箇条と表を、上の「新たに宣言する保証」と「変更する保証」に合わせて直す。
50 changes: 46 additions & 4 deletions tests/test_example_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
import re
import shutil
import stat
import subprocess
from pathlib import Path

import pytest

from conftest import ROOT
from conftest import ROOT, git

EXAMPLE = ROOT / "examples" / "plugins" / "sessions.py"

Expand Down Expand Up @@ -231,12 +232,53 @@ def test_build_survives_one_failing_config_dir(site, ccreel):
assert "warning: sessions: ccreel list failed for ~/.claude-broken" in proc.stderr


def test_build_survives_missing_ccreel(site, ccreel):
def _assert_build_stops(site, *reasons: str) -> None:
_, proc = site.build(check=False)
assert proc.returncode != 0
assert "error:" in proc.stderr
for reason in reasons:
assert reason in proc.stderr
assert not (Path(site.env["CODEVIEW_OUT"]) / "public").exists()


def test_build_stops_without_ccreel(site, ccreel):
_use_sessions(site, '\n[plugins.sessions]\nccreel = "/nonexistent/ccreel"\n')
public, proc = site.build()
_assert_build_stops(site, "~/.claude", "/nonexistent/ccreel")


def test_build_stops_when_every_list_fails(site, ccreel):
_use_sessions(site, '\n[plugins.sessions]\nconfig_dirs = ["~/.claude", "~/.claude-broken"]\n')
(ccreel / "list.jsonl").unlink()
(ccreel / "list-.claude-broken.jsonl").write_text("not json\n")
# config dir ごとの理由が出る。1つ目は偽の ccreel の標準エラー、2つ目は JSON の誤り
_assert_build_stops(site, "~/.claude: no session", "~/.claude-broken: ")


@pytest.mark.parametrize("field", ["id", "repo", "title", "prompts", "started", "updated"])
def test_build_stops_when_a_list_row_lacks_a_field(site, ccreel, field):
row = {k: v for k, v in SESSIONS[0].items() if k != field}
(ccreel / "list.jsonl").write_text(json.dumps(row) + "\n")
_use_sessions(site)
_assert_build_stops(site, "~/.claude", f"'{field}'")


def test_build_with_empty_list_is_not_an_error(site, ccreel):
(ccreel / "list.jsonl").write_text("")
_use_sessions(site)
public, _ = site.build()
assert (public / "index.html").is_file()
assert not (public / "sessions").exists()
assert "warning: sessions" in proc.stderr


def test_publish_if_changed_does_not_push_when_list_fails(site, ccreel, tmp_path):
remote = tmp_path / "remote.git"
subprocess.run(["git", "init", "-q", "--bare", str(remote)], check=True)
git(site.config.parent, "remote", "add", "origin", str(remote))
_use_sessions(site, '\n[plugins.sessions]\nccreel = "/nonexistent/ccreel"\n')
proc = site.run("-m", "codeview", "publish", "--if-changed", check=False)
assert proc.returncode != 0
assert "/nonexistent/ccreel" in proc.stderr
assert git(remote, "branch", "--list", "deploy") == ""


@pytest.mark.parametrize(
Expand Down
Loading