From f879fd6bc39dc08f4857f168cfe527ccb8e5c1b4 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Thu, 28 May 2026 08:19:49 +0200 Subject: [PATCH] fix: add dry-run discoverability safe repair --- CHANGELOG.md | 3 + api/session_discoverability.py | 196 ++++++++++++++++++- tests/test_session_discoverability_repair.py | 175 +++++++++++++++++ 3 files changed, 372 insertions(+), 2 deletions(-) create mode 100644 tests/test_session_discoverability_repair.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a990d204abc..3ac7e23e1d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## [Unreleased] +### Added +- Session discoverability audit now has a default-dry-run `--repair-safe` routine for deterministic cleanup: stale persisted WebUI-as-CLI flags can be cleared from sidecars/index entries, and messageful WebUI rows present only in `state.db` can be materialized into sidecars/index entries when `--apply --backup-dir ` is explicitly provided. + ## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat) ### Added diff --git a/api/session_discoverability.py b/api/session_discoverability.py index 0f218561bc5..a04ca753517 100644 --- a/api/session_discoverability.py +++ b/api/session_discoverability.py @@ -12,6 +12,8 @@ import argparse import json +import os +import shutil import sqlite3 from collections import Counter from pathlib import Path @@ -373,6 +375,184 @@ def audit_session_discoverability( } +def _atomic_write_json(path: Path, payload) -> None: + tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}") + tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, path) + + +def _backup_file(path: Path, backup_dir: Path, backed_up: dict[Path, str]) -> str | None: + if not path.exists(): + return None + resolved = path.resolve() + if resolved in backed_up: + return backed_up[resolved] + backup_dir.mkdir(parents=True, exist_ok=True) + target = backup_dir / path.name + if target.exists(): + stem = target.name + i = 1 + while (backup_dir / f"{stem}.{i}").exists(): + i += 1 + target = backup_dir / f"{stem}.{i}" + shutil.copy2(path, target) + backed_up[resolved] = str(target) + return str(target) + + +def _plan_discoverability_repairs(report: dict) -> list[dict]: + actions: list[dict] = [] + for item in report.get("items") or []: + sid = str(item.get("session_id") or "") + if not sid: + continue + if item.get("kind") == "persisted_source_flag_stale": + if item.get("sidecar_is_cli_session") is True: + actions.append({"session_id": sid, "action": "clear_sidecar_cli_flag"}) + if item.get("index_is_cli_session") is True: + actions.append({"session_id": sid, "action": "clear_index_cli_flag"}) + elif item.get("kind") == "state_db_messageful_missing_sidecar": + actions.append({"session_id": sid, "action": "materialize_sidecar_from_state_db"}) + return actions + + +def _clear_sidecar_cli_flag(session_dir: Path, sid: str, backup_dir: Path, backed_up: dict[Path, str]) -> dict: + path = session_dir / f"{sid}.json" + payload = _read_json(path) + if not isinstance(payload, dict): + return {"session_id": sid, "action": "clear_sidecar_cli_flag", "applied": False, "error": "sidecar_unreadable"} + if not _webui_origin(payload): + return {"session_id": sid, "action": "clear_sidecar_cli_flag", "applied": False, "skipped": "not_webui_origin"} + if payload.get("is_cli_session") is not True: + return {"session_id": sid, "action": "clear_sidecar_cli_flag", "applied": False, "skipped": "already_clear"} + backup = _backup_file(path, backup_dir, backed_up) + payload["is_cli_session"] = False + _atomic_write_json(path, payload) + return {"session_id": sid, "action": "clear_sidecar_cli_flag", "applied": True, "backup": backup} + + +def _clear_index_cli_flag(session_dir: Path, sid: str, backup_dir: Path, backed_up: dict[Path, str]) -> dict: + path = session_dir / "_index.json" + payload = _read_json(path) + if not isinstance(payload, list): + return {"session_id": sid, "action": "clear_index_cli_flag", "applied": False, "error": "index_unreadable"} + changed = False + for entry in payload: + if not isinstance(entry, dict): + continue + if str(entry.get("session_id") or "") != sid: + continue + if not _webui_origin(entry): + continue + if entry.get("is_cli_session") is True: + entry["is_cli_session"] = False + changed = True + if not changed: + return {"session_id": sid, "action": "clear_index_cli_flag", "applied": False, "skipped": "already_clear_or_missing"} + backup = _backup_file(path, backup_dir, backed_up) + _atomic_write_json(path, payload) + return {"session_id": sid, "action": "clear_index_cli_flag", "applied": True, "backup": backup} + + +def _materialize_sidecar_from_state_db(session_dir: Path, state_db_path: Path | None, sid: str, backup_dir: Path, backed_up: dict[Path, str]) -> dict: + if state_db_path is None: + return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "error": "state_db_required"} + target = session_dir / f"{sid}.json" + if target.exists(): + return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "skipped": "sidecar_exists"} + try: + from api.session_recovery import _read_state_db_missing_sidecar_rows, _state_db_row_to_sidecar + except Exception as exc: + return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "error": f"recovery_import_failed:{exc}"} + rows = {str(row.get("id") or ""): row for row in _read_state_db_missing_sidecar_rows(session_dir, state_db_path)} + row = rows.get(sid) + if not row: + return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "skipped": "state_row_not_repairable"} + payload = _state_db_row_to_sidecar(row) + _backup_file(state_db_path, backup_dir, backed_up) + session_dir.mkdir(parents=True, exist_ok=True) + tmp = target.with_suffix(target.suffix + f".tmp.{os.getpid()}") + tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + try: + os.link(str(tmp), str(target)) + except FileExistsError: + return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "skipped": "sidecar_appeared_during_repair"} + finally: + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + index_updated = False + index_path = session_dir / "_index.json" + index_payload = _read_json(index_path) + if not isinstance(index_payload, list): + index_payload = [] + if not any(isinstance(entry, dict) and str(entry.get("session_id") or "") == sid for entry in index_payload): + _backup_file(index_path, backup_dir, backed_up) + index_entry = {key: value for key, value in payload.items() if key not in {"messages", "tool_calls"}} + index_payload.append(index_entry) + _atomic_write_json(index_path, index_payload) + index_updated = True + return { + "session_id": sid, + "action": "materialize_sidecar_from_state_db", + "applied": True, + "messages": len(payload.get("messages") or []), + "index_updated": index_updated, + "backup": str((backup_dir / state_db_path.name)) if (backup_dir / state_db_path.name).exists() else None, + } + + +def repair_session_discoverability( + session_dir: Path, + state_db_path: Path | None = None, + *, + api_sessions: Iterable[dict] | None = None, + dry_run: bool = True, + backup_dir: Path | None = None, +) -> dict: + """Plan or apply deterministic discoverability repairs. + + Default mode is read-only. Applying mutations requires ``backup_dir`` and is + limited to stale persisted WebUI-as-CLI flags plus materializing WebUI + messageful sidecars from canonical state.db rows. + """ + before = audit_session_discoverability(session_dir, state_db_path=state_db_path, api_sessions=api_sessions) + planned = _plan_discoverability_repairs(before) + if dry_run: + return {"ok": True, "dry_run": True, "planned": planned, "applied": [], "before": before, "after": before} + if backup_dir is None: + return {"ok": False, "dry_run": False, "error": "backup_dir_required_for_apply", "planned": planned, "applied": [], "before": before} + + session_dir = Path(session_dir) + backup_dir = Path(backup_dir) + backed_up: dict[Path, str] = {} + applied: list[dict] = [] + for action in planned: + sid = str(action.get("session_id") or "") + name = action.get("action") + try: + if name == "clear_sidecar_cli_flag": + applied.append(_clear_sidecar_cli_flag(session_dir, sid, backup_dir, backed_up)) + elif name == "clear_index_cli_flag": + applied.append(_clear_index_cli_flag(session_dir, sid, backup_dir, backed_up)) + elif name == "materialize_sidecar_from_state_db": + applied.append(_materialize_sidecar_from_state_db(session_dir, state_db_path, sid, backup_dir, backed_up)) + except Exception as exc: + applied.append({"session_id": sid, "action": name, "applied": False, "error": str(exc)}) + after = audit_session_discoverability(session_dir, state_db_path=state_db_path, api_sessions=api_sessions) + errors = [item for item in applied if item.get("error")] + return { + "ok": not errors, + "dry_run": False, + "planned": planned, + "applied": applied, + "backups": sorted(set(backed_up.values())), + "before": before, + "after": after, + } + + def render_discoverability_markdown(report: dict) -> str: lines = [ "# WebUI Session Discoverability Audit", @@ -416,11 +596,23 @@ def _main() -> int: parser.add_argument("--session-dir", type=Path, required=True) parser.add_argument("--state-db", type=Path, default=None) parser.add_argument("--format", choices=("json", "markdown"), default="json") + parser.add_argument("--repair-safe", action="store_true", help="Plan/apply deterministic discoverability repairs") + parser.add_argument("--apply", action="store_true", help="Apply --repair-safe changes; default is dry-run") + parser.add_argument("--backup-dir", type=Path, default=None, help="Required with --repair-safe --apply") parser.add_argument("--out", type=Path, default=None) args = parser.parse_args() - report = audit_session_discoverability(args.session_dir, state_db_path=args.state_db) - text = render_discoverability_markdown(report) if args.format == "markdown" else json.dumps(report, sort_keys=True) + if args.repair_safe: + report = repair_session_discoverability( + args.session_dir, + state_db_path=args.state_db, + dry_run=not args.apply, + backup_dir=args.backup_dir, + ) + text = json.dumps(report, sort_keys=True) + else: + report = audit_session_discoverability(args.session_dir, state_db_path=args.state_db) + text = render_discoverability_markdown(report) if args.format == "markdown" else json.dumps(report, sort_keys=True) if args.out: args.out.parent.mkdir(parents=True, exist_ok=True) args.out.write_text(text, encoding="utf-8") diff --git a/tests/test_session_discoverability_repair.py b/tests/test_session_discoverability_repair.py new file mode 100644 index 00000000000..c43c9fc11b3 --- /dev/null +++ b/tests/test_session_discoverability_repair.py @@ -0,0 +1,175 @@ +import json +import sqlite3 +import subprocess +import sys +from pathlib import Path + +from api.session_discoverability import repair_session_discoverability + + +def _write_sidecar(session_dir: Path, sid: str, *, messages=1, **metadata): + payload = { + "session_id": sid, + "id": sid, + "title": metadata.pop("title", sid), + "messages": [{"role": "user", "content": f"message {i}"} for i in range(messages)], + **metadata, + } + path = session_dir / f"{sid}.json" + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + +def _write_index(session_dir: Path, *entries): + (session_dir / "_index.json").write_text(json.dumps(list(entries)), encoding="utf-8") + + +def _state_db(session_dir: Path, rows, message_counts=None): + db = session_dir / "state.db" + message_counts = message_counts or {} + with sqlite3.connect(db) as conn: + conn.execute( + """ + create table sessions ( + id text primary key, + source text, + title text, + parent_session_id text, + message_count integer, + started_at real, + model text, + workspace text + ) + """ + ) + conn.execute("create table messages (id integer primary key, session_id text, role text, content text, timestamp real)") + for row in rows: + conn.execute( + """ + insert into sessions (id, source, title, parent_session_id, message_count, started_at, model, workspace) + values (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + row["id"], + row.get("source"), + row.get("title") or row["id"], + row.get("parent_session_id"), + row.get("message_count", message_counts.get(row["id"], 0)), + row.get("started_at", 10.0), + row.get("model", "gpt-test"), + row.get("workspace", "/tmp/workspace"), + ), + ) + for i in range(message_counts.get(row["id"], 0)): + conn.execute( + "insert into messages (session_id, role, content, timestamp) values (?, 'user', ?, ?)", + (row["id"], f"message {i}", 10.0 + i), + ) + return db + + +def test_repair_discoverability_dry_run_plans_without_mutating_files(tmp_path): + stale = "webui-stale-cli-flag" + missing = "state-only-messageful" + _write_sidecar(tmp_path, stale, messages=3, source_tag="webui", session_source="webui", is_cli_session=True) + _write_index(tmp_path, {"session_id": stale, "message_count": 3, "source_tag": "webui", "session_source": "webui", "is_cli_session": True}) + db = _state_db( + tmp_path, + [ + {"id": stale, "source": "webui", "message_count": 3}, + {"id": missing, "source": "webui", "message_count": 2}, + ], + {stale: 3, missing: 2}, + ) + + result = repair_session_discoverability(tmp_path, state_db_path=db, dry_run=True, backup_dir=tmp_path / "backup") + + assert result["dry_run"] is True + assert result["applied"] == [] + assert {action["action"] for action in result["planned"]} == { + "clear_sidecar_cli_flag", + "clear_index_cli_flag", + "materialize_sidecar_from_state_db", + } + assert json.loads((tmp_path / f"{stale}.json").read_text())["is_cli_session"] is True + assert json.loads((tmp_path / "_index.json").read_text())[0]["is_cli_session"] is True + assert not (tmp_path / f"{missing}.json").exists() + assert not (tmp_path / "backup").exists() + + +def test_repair_discoverability_apply_requires_backup_dir(tmp_path): + sid = "webui-stale-cli-flag" + _write_sidecar(tmp_path, sid, messages=1, source_tag="webui", session_source="webui", is_cli_session=True) + _write_index(tmp_path, {"session_id": sid, "message_count": 1, "source_tag": "webui", "session_source": "webui", "is_cli_session": True}) + db = _state_db(tmp_path, [{"id": sid, "source": "webui", "message_count": 1}], {sid: 1}) + + result = repair_session_discoverability(tmp_path, state_db_path=db, dry_run=False) + + assert result["ok"] is False + assert result["error"] == "backup_dir_required_for_apply" + assert json.loads((tmp_path / f"{sid}.json").read_text())["is_cli_session"] is True + + +def test_repair_discoverability_apply_backs_up_and_repairs_safe_findings(tmp_path): + stale = "webui-stale-cli-flag" + missing = "state-only-messageful" + _write_sidecar(tmp_path, stale, messages=3, source_tag="webui", session_source="webui", is_cli_session=True) + _write_index(tmp_path, {"session_id": stale, "message_count": 3, "source_tag": "webui", "session_source": "webui", "is_cli_session": True}) + db = _state_db( + tmp_path, + [ + {"id": stale, "source": "webui", "message_count": 3}, + {"id": missing, "source": "webui", "message_count": 2, "title": "Recovered From State"}, + ], + {stale: 3, missing: 2}, + ) + + result = repair_session_discoverability(tmp_path, state_db_path=db, dry_run=False, backup_dir=tmp_path / "backup") + + assert result["ok"] is True + assert result["dry_run"] is False + assert {action["action"] for action in result["applied"]} == { + "clear_sidecar_cli_flag", + "clear_index_cli_flag", + "materialize_sidecar_from_state_db", + } + assert json.loads((tmp_path / f"{stale}.json").read_text())["is_cli_session"] is False + assert json.loads((tmp_path / "_index.json").read_text())[0]["is_cli_session"] is False + index_rows = json.loads((tmp_path / "_index.json").read_text()) + assert {row["session_id"] for row in index_rows} == {stale, missing} + recovered = json.loads((tmp_path / f"{missing}.json").read_text()) + assert recovered["title"] == "Recovered From State" + assert recovered["message_count"] == 2 + assert len(recovered["messages"]) == 2 + backed_up = {p.name for p in (tmp_path / "backup").iterdir()} + assert f"{stale}.json" in backed_up + assert "_index.json" in backed_up + assert "state.db" in backed_up + + +def test_repair_discoverability_cli_defaults_to_dry_run(tmp_path): + sid = "webui-stale-cli-flag" + _write_sidecar(tmp_path, sid, messages=1, source_tag="webui", session_source="webui", is_cli_session=True) + _write_index(tmp_path, {"session_id": sid, "message_count": 1, "source_tag": "webui", "session_source": "webui", "is_cli_session": True}) + db = _state_db(tmp_path, [{"id": sid, "source": "webui", "message_count": 1}], {sid: 1}) + + completed = subprocess.run( + [ + sys.executable, + "-m", + "api.session_discoverability", + "--repair-safe", + "--session-dir", + str(tmp_path), + "--state-db", + str(db), + ], + check=True, + text=True, + capture_output=True, + ) + + result = json.loads(completed.stdout) + assert result["dry_run"] is True + assert [action["action"] for action in result["planned"]] == ["clear_sidecar_cli_flag", "clear_index_cli_flag"] + assert json.loads((tmp_path / f"{sid}.json").read_text())["is_cli_session"] is True