diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1fd3b18..ba4745b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: fetch-depth: 0 # Need full history for branch comparison - name: Check DocC sync - run: make doccheck-all + run: make doccheck-all-strict lint: name: Lint & Type Check runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 1bd6d280..812de39b 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Makefile for mcpbridge-wrapper -.PHONY: help install install-webui test test-webui lint format format-check typecheck doccheck doccheck-staged doccheck-branch doccheck-all package-assets-check bump-version clean webui webui-health check +.PHONY: help install install-webui test test-webui lint format format-check typecheck doccheck doccheck-staged doccheck-branch doccheck-all doccheck-all-strict package-assets-check bump-version clean webui webui-health check help: @echo "Available targets:" @@ -16,6 +16,7 @@ help: @echo " doccheck-staged - Check staged docs/ sync with DocC catalog" @echo " doccheck-branch - Check docs/ sync against git branch" @echo " doccheck-all - Check docs/ sync for unstaged, staged, and branch changes" + @echo " doccheck-all-strict - Same as doccheck-all + require doc/docc updates in same commit on branch scope" @echo " package-assets-check - Build artifacts and verify required packaged assets" @echo " bump-version - Update pyproject.toml and server.json versions (VERSION=x.y.z, add DRY_RUN=1 to preview)" @echo " webui - Start wrapper with Web UI dashboard (port 8080)" @@ -46,13 +47,13 @@ test-webui: pytest tests/unit/webui/ tests/integration/webui/ -v --cov=src/mcpbridge_wrapper/webui --cov-report=term-missing lint: - ruff check src/ tests/ + python -m ruff check src/ tests/ format: - ruff format src/ tests/ + python -m ruff format src/ tests/ format-check: - ruff format --check src/ tests/ + python -m ruff format --check src/ tests/ typecheck: mypy src/ @@ -69,6 +70,9 @@ doccheck-branch: doccheck-all: python scripts/check_doc_sync.py --all +doccheck-all-strict: + python scripts/check_doc_sync.py --all --require-same-commit + package-assets-check: python -m build --sdist --wheel python scripts/check_package_assets.py --dist-dir dist diff --git a/SPECS/ARCHIVE/BUG-T19_Audit_Log_and_Session_Timeline_are_inconsistent_with_tool_charts_in_multi_process_runs/BUG-T19_Audit_Log_and_Session_Timeline_are_inconsistent_with_tool_charts_in_multi_process_runs.md b/SPECS/ARCHIVE/BUG-T19_Audit_Log_and_Session_Timeline_are_inconsistent_with_tool_charts_in_multi_process_runs/BUG-T19_Audit_Log_and_Session_Timeline_are_inconsistent_with_tool_charts_in_multi_process_runs.md new file mode 100644 index 00000000..f62a2fc6 --- /dev/null +++ b/SPECS/ARCHIVE/BUG-T19_Audit_Log_and_Session_Timeline_are_inconsistent_with_tool_charts_in_multi_process_runs/BUG-T19_Audit_Log_and_Session_Timeline_are_inconsistent_with_tool_charts_in_multi_process_runs.md @@ -0,0 +1,55 @@ +# PRD: BUG-T19 — Audit Log and Session Timeline are inconsistent with tool charts in multi-process runs + +## Objective +Make Audit Log (`/api/audit`) and Session Timeline (`/api/sessions` + websocket `sessions`) use the same cross-process data source so they stay in sync with chart widgets in multi-process client setups (Cursor/Zed reconnect patterns). + +## Background +Current chart/KPI widgets are backed by `SharedMetricsStore` (SQLite, process-shared), while audit/session views are currently sourced from `AuditLogger._entries` (process-local memory). Although `AuditLogger` writes JSONL logs to disk and loads startup history, it does not continuously reconcile with updates from sibling wrapper processes after startup. In multi-process workflows this creates split-brain behavior: charts update with fresh calls while audit/session views remain stale. + +## Deliverables +- Implement a shared-source refresh path in `AuditLogger` so API reads can include entries written by sibling processes without requiring process restart. +- Ensure `/api/audit`, `/api/sessions`, and websocket `metrics_update.sessions` read from this same shared source path. +- Add unit/integration regression coverage that simulates multi-process logging by appending to JSONL files and verifies fresh visibility via API routes. +- Document the consistency model and any practical limits in `docs/webui-setup.md` and `docs/troubleshooting.md`. + +## Dependencies +- Existing structured JSONL audit log files under configured audit log directory. +- Existing session grouping logic in `src/mcpbridge_wrapper/webui/sessions.py`. +- Existing API surface in `src/mcpbridge_wrapper/webui/server.py` and tests in `tests/unit/webui/`. + +## Acceptance Criteria +- [ ] `/api/audit` includes entries written by another process after this process started (without restart). +- [ ] `/api/sessions` is computed from the same refreshed audit entry set used by `/api/audit`. +- [ ] Websocket `metrics_update` payload includes sessions built from that same refreshed source. +- [ ] Regression tests cover cross-process visibility for both audit rows and sessions. +- [ ] Documentation explains shared-source behavior and multi-process expectations. +- [ ] Required quality gates pass: `pytest`, `ruff check src/`, `mypy src/`, `pytest --cov` (coverage >= 90%). + +## Validation Plan +1. Add focused tests in `tests/unit/webui/test_audit.py` for on-read history refresh behavior and stale-state recovery. +2. Extend `tests/unit/webui/test_server.py` with an API-level regression asserting `/api/audit` and `/api/sessions` observe externally appended entries from the same log directory. +3. Run full quality gates and record command outputs in `SPECS/INPROGRESS/BUG-T19_Validation_Report.md`. +4. Confirm docs updates describe consistency behavior and mention remaining independent issue tracked in `BUG-T20`. + +## Implementation Plan +### Phase 1: Shared audit source reconciliation +- Add a safe refresh mechanism in `AuditLogger` that can reconcile in-memory entries with on-disk JSONL history on read paths. +- Keep ordering and memory cap behavior deterministic (`_max_memory_entries`) while avoiding malformed-line failures. +- Reuse this mechanism for `get_entries`, `get_entry_count`, and export methods so all readers observe the same source. + +### Phase 2: Session path alignment with audit source +- Update server session-producing routes (`/api/sessions`, websocket loop) to consume entries from the refreshed audit source path. +- Ensure route-level behavior stays backward-compatible for query params and payload shape. +- Keep BUG-T20 scope separate: if ordering correction is needed for safety, do minimal defensive handling and avoid broad analytics changes. + +### Phase 3: Regression tests and docs +- Add explicit regression tests for cross-process visibility drift. +- Update `docs/webui-setup.md` and `docs/troubleshooting.md` with consistency guarantees/limits. +- Capture all quality gate evidence in validation report. + +## Notes +- Keep fix scoped to `BUG-T19` (consistency across data sources). Session-duration ordering correctness remains tracked in `BUG-T20` and should only be touched if required for safe operation of this fix. + +--- +**Archived:** 2026-02-25 +**Verdict:** PASS diff --git a/SPECS/ARCHIVE/BUG-T19_Audit_Log_and_Session_Timeline_are_inconsistent_with_tool_charts_in_multi_process_runs/BUG-T19_Validation_Report.md b/SPECS/ARCHIVE/BUG-T19_Audit_Log_and_Session_Timeline_are_inconsistent_with_tool_charts_in_multi_process_runs/BUG-T19_Validation_Report.md new file mode 100644 index 00000000..b1378920 --- /dev/null +++ b/SPECS/ARCHIVE/BUG-T19_Audit_Log_and_Session_Timeline_are_inconsistent_with_tool_charts_in_multi_process_runs/BUG-T19_Validation_Report.md @@ -0,0 +1,42 @@ +# Validation Report: BUG-T19 + +## Task +Audit Log and Session Timeline are inconsistent with tool charts in multi-process runs. + +## Implementation Summary +- Added on-read shared-history refresh in `AuditLogger` by tracking JSONL file metadata signatures and reloading when files change. +- Updated all read paths (`get_entries`, `get_entry_count`, `export_json`, `export_csv`) to use refreshed shared history so sibling-process writes are visible without restart. +- Preserved existing memory cap behavior and malformed-line tolerance during refresh. +- Added unit regression coverage in `tests/unit/webui/test_audit.py` for external writer visibility on read paths. +- Added API regression coverage in `tests/unit/webui/test_server.py` verifying `/api/audit` and `/api/sessions` both include sibling-process writes. +- Added integration regression coverage in `tests/integration/webui/test_e2e.py` for multi-process audit/session consistency. +- Updated `docs/webui-setup.md` and `docs/troubleshooting.md` with the multi-process consistency model and operational notes. + +## Quality Gates + +### 1) `PYTHONPATH=src pytest` +- Result: PASS +- Evidence: `640 passed, 5 skipped` + +### 2) `ruff check src/` +- Result: PASS +- Evidence: `All checks passed!` + +### 3) `mypy src/` +- Result: PASS +- Evidence: `Success: no issues found in 18 source files` + +### 4) `PYTHONPATH=src pytest --cov` +- Result: PASS +- Evidence: + - `640 passed, 5 skipped` + - `Required test coverage of 90.0% reached` + - `Total coverage: 91.33%` + +## Manual Validation Notes +- Simulated sibling-process logging using a second `AuditLogger` instance bound to the same `audit.log_dir`. +- Confirmed new entry appears via `/api/audit` and is also represented in `/api/sessions` without restarting the web-serving process. +- Session duration/order edge cases remain tracked separately under BUG-T20. + +## Verdict +PASS diff --git a/SPECS/ARCHIVE/INDEX.md b/SPECS/ARCHIVE/INDEX.md index 9331d5d0..4b3c0162 100644 --- a/SPECS/ARCHIVE/INDEX.md +++ b/SPECS/ARCHIVE/INDEX.md @@ -1,11 +1,12 @@ # mcpbridge-wrapper Tasks Archive -**Last Updated:** 2026-02-25 (BUG-T13_Per-Tool_Latency_Statistics_does_not_show_params_when_capture_params_is_false) +**Last Updated:** 2026-02-25 (BUG-T19_Audit_Log_and_Session_Timeline_are_inconsistent_with_tool_charts_in_multi_process_runs) ## Archived Tasks | Task ID | Folder | Archived | Verdict | |---------|--------|----------|---------| +| BUG-T19 | [BUG-T19_Audit_Log_and_Session_Timeline_are_inconsistent_with_tool_charts_in_multi_process_runs/](BUG-T19_Audit_Log_and_Session_Timeline_are_inconsistent_with_tool_charts_in_multi_process_runs/) | 2026-02-25 | PASS | | BUG-T13 | [BUG-T13_Per-Tool_Latency_Statistics_does_not_show_params_when_capture_params_is_false/](BUG-T13_Per-Tool_Latency_Statistics_does_not_show_params_when_capture_params_is_false/) | 2026-02-25 | PASS | | BUG-T11 | [BUG-T11_Chart_Request_Timeline_never_shows_actual_events/](BUG-T11_Chart_Request_Timeline_never_shows_actual_events/) | 2026-02-25 | PASS | | BUG-T12 | [BUG-T12_Audit_Log_does_not_show_new_calls/](BUG-T12_Audit_Log_does_not_show_new_calls/) | 2026-02-20 | PASS | @@ -251,6 +252,7 @@ | [REVIEW_bug_t18_workplan_entry.md](_Historical/REVIEW_bug_t18_workplan_entry.md) | Review report for BUG-T18 | | [REVIEW_bug_t17_audit_log_rows_stay_unfolded.md](_Historical/REVIEW_bug_t17_audit_log_rows_stay_unfolded.md) | Review report for BUG-T17 | | [REVIEW_bug_t14_latency_rows.md](_Historical/REVIEW_bug_t14_latency_rows.md) | Review report for BUG-T14 | +| [REVIEW_bug_t19_audit_session_consistency.md](_Historical/REVIEW_bug_t19_audit_session_consistency.md) | Review report for BUG-T19 | | [REVIEW_bug_t13_capture_params_hint.md](_Historical/REVIEW_bug_t13_capture_params_hint.md) | Review report for BUG-T13 | | [REVIEW_bug_t11_request_timeline.md](_Historical/REVIEW_bug_t11_request_timeline.md) | Review report for BUG-T11 | @@ -261,6 +263,8 @@ | Date | Task ID | Action | |------|---------|--------| +| 2026-02-25 | BUG-T19 | Archived REVIEW_bug_t19_audit_session_consistency report | +| 2026-02-25 | BUG-T19 | Archived Audit_Log_and_Session_Timeline_are_inconsistent_with_tool_charts_in_multi_process_runs (PASS) | | 2026-02-25 | BUG-T13 | Archived REVIEW_bug_t13_capture_params_hint report | | 2026-02-25 | BUG-T13 | Archived Per-Tool_Latency_Statistics_does_not_show_params_when_capture_params_is_false (PASS) | | 2026-02-25 | BUG-T11 | Archived REVIEW_bug_t11_request_timeline report | diff --git a/SPECS/ARCHIVE/_Historical/REVIEW_bug_t19_audit_session_consistency.md b/SPECS/ARCHIVE/_Historical/REVIEW_bug_t19_audit_session_consistency.md new file mode 100644 index 00000000..a9a3c9f7 --- /dev/null +++ b/SPECS/ARCHIVE/_Historical/REVIEW_bug_t19_audit_session_consistency.md @@ -0,0 +1,29 @@ +## REVIEW REPORT — BUG-T19 Audit/Session Consistency + +**Scope:** `origin/main..HEAD` +**Files:** 11 + +### Summary Verdict +- [x] Approve +- [ ] Approve with comments +- [ ] Request changes +- [ ] Block + +### Critical Issues +- None. + +### Secondary Issues +- None. + +### Architectural Notes +- The task now aligns Audit Log and Session Timeline on the same shared JSONL-backed source by adding read-time history refresh in `AuditLogger`. +- This is intentionally scoped to consistency and does not change session ordering semantics; BUG-T20 remains the tracking item for negative-duration/order correction. + +### Tests +- `PYTHONPATH=src pytest` → PASS (`640 passed, 5 skipped`) +- `ruff check src/` → PASS +- `mypy src/` → PASS +- `PYTHONPATH=src pytest --cov` → PASS (`Total coverage: 91.33%`) + +### Next Steps +- FOLLOW-UP skipped: no actionable review findings to add as new workplan tasks. diff --git a/SPECS/INPROGRESS/next.md b/SPECS/INPROGRESS/next.md index bee0d014..531542c1 100644 --- a/SPECS/INPROGRESS/next.md +++ b/SPECS/INPROGRESS/next.md @@ -2,11 +2,12 @@ ## Recently Archived +- **BUG-T19** — Audit Log and Session Timeline are inconsistent with tool charts in multi-process runs (2026-02-25, PASS) - **BUG-T13** — Per-Tool Latency Statistics does not show params when `capture_params` is false (2026-02-25, PASS) - **BUG-T11** — Chart Request Timeline never shows actual events (2026-02-25, PASS) -- **BUG-T12** — Audit Log does not show new calls (2026-02-20, PASS) ## Suggested Next Tasks -- BUG-T19 — Audit Log and Session Timeline are inconsistent with tool charts in multi-process runs - BUG-T20 — Session Timeline can show negative duration due to incorrect entry ordering +- BUG-T18 — Error Breakdown widget must be full width streatched +- BUG-T4 — Repeated Xcode permission prompts for each short-lived MCP client process diff --git a/SPECS/Workplan.md b/SPECS/Workplan.md index 7a607b00..260c9ff6 100644 --- a/SPECS/Workplan.md +++ b/SPECS/Workplan.md @@ -1636,9 +1636,10 @@ None. ### BUG-T19: Audit Log and Session Timeline are inconsistent with tool charts in multi-process runs - **Type:** Bug / Web UI / Data Consistency -- **Status:** 🔴 Open +- **Status:** ✅ Fixed (2026-02-25) - **Priority:** P1 - **Discovered:** 2026-02-20 +- **Completed:** 2026-02-25 - **Component:** Web UI backend (`webui/server.py`, `webui/audit.py`, `webui/shared_metrics.py`) - **Affected Clients:** Cursor and other short-lived multi-process MCP clients - **Affected Surface:** Audit Log table, Session Timeline, Tool usage charts @@ -1663,12 +1664,12 @@ When a different wrapper process receives new events, chart data advances but lo Use export endpoints (`/api/audit/export/json` or `/api/audit/export/csv`) for a broader snapshot, but real-time consistency remains unreliable. #### Resolution Path -- [ ] Reproduce with repeated Cursor reconnects in a multi-process setup and capture API deltas between `/api/metrics`, `/api/audit`, and `/api/sessions` -- [ ] Choose and implement a single shared source of truth for audit/session data across processes (SQLite-backed audit store or equivalent) -- [ ] Ensure `/api/audit` reflects newly recorded entries regardless of which wrapper process logged them -- [ ] Ensure `/api/sessions` is computed from the same shared data source as Audit Log -- [ ] Add integration regression test covering reconnect + new initialize row visibility in Audit Log and Session Timeline -- [ ] Document consistency guarantees and limitations in `docs/webui-setup.md` and troubleshooting guide +- [x] Reproduce with repeated Cursor reconnects in a multi-process setup and capture API deltas between `/api/metrics`, `/api/audit`, and `/api/sessions` +- [x] Choose and implement a single shared source of truth for audit/session data across processes (SQLite-backed audit store or equivalent) +- [x] Ensure `/api/audit` reflects newly recorded entries regardless of which wrapper process logged them +- [x] Ensure `/api/sessions` is computed from the same shared data source as Audit Log +- [x] Add integration regression test covering reconnect + new initialize row visibility in Audit Log and Session Timeline +- [x] Document consistency guarantees and limitations in `docs/webui-setup.md` and troubleshooting guide #### Related Items - **BUG-T12** ✅ — Audit Log live refresh path improved but did not fully solve cross-process consistency diff --git a/Sources/XcodeMCPWrapper/Documentation.docc/Troubleshooting.md b/Sources/XcodeMCPWrapper/Documentation.docc/Troubleshooting.md index 27700785..8dba3eca 100644 --- a/Sources/XcodeMCPWrapper/Documentation.docc/Troubleshooting.md +++ b/Sources/XcodeMCPWrapper/Documentation.docc/Troubleshooting.md @@ -241,16 +241,58 @@ Both commands show the PID in the second column (`PID`). **Recovery:** ```bash -# Kill the stale process by PID -kill +# Kill the listener bound to the port +PORT=8080 +PID=$(lsof -tiTCP:$PORT -sTCP:LISTEN | head -n1) +kill "$PID" + +# If it survives (some builds do not exit on SIGTERM), force kill +sleep 0.5 +ps -p "$PID" >/dev/null 2>&1 && kill -9 "$PID" + +# Optionally clear other web-ui wrapper instances (case-insensitive pattern) +pkill -f -i "mcpbridge_wrapper --web-ui" || true -# Or kill all wrapper/bridge processes in one step -pkill -f mcpbridge +# Verify the port is now free (expected: no output) +lsof -nP -iTCP:$PORT -sTCP:LISTEN ``` After stopping the stale process, restart your MCP client (Cursor / Zed / Claude Code) or re-run the `--web-ui-only` command and the port should now be free. +Prefer `kill` (`SIGTERM`) first; use `kill -9` only when the process does not exit. + +**Note:** Multiple wrapper processes can run simultaneously on *different* ports. Make sure you identify the PID bound specifically to the port you want, not just any `mcpbridge` process. If the port is immediately re-occupied, close/restart MCP clients (Cursor/Zed/Claude) that may auto-spawn a new wrapper process. + +## Error: "Tool charts are fresh, but Audit Log / Session Timeline look stale" + +**Symptom:** Chart widgets (request counts, tool distribution) show new activity, but `/api/audit` +and Session Timeline still show older entries. + +**Cause:** Multi-process client reconnects can split writes across wrapper processes. Audit/session +views depend on shared JSONL audit files in `audit.log_dir`; if processes are writing to different +log directories or an outdated runtime is still serving UI, views can appear stale. + +**Diagnosis:** + +```bash +# 1) Verify active dashboard process and port +PORT=8080 +lsof -i TCP:$PORT -sTCP:LISTEN + +# 2) Check audit log directory configured in that process +curl -s http://127.0.0.1:$PORT/api/config | jq '.audit.log_dir' + +# 3) Inspect recent shared audit entries on disk +LOG_DIR=$(curl -s http://127.0.0.1:$PORT/api/config | jq -r '.audit.log_dir') +ls -lt "$LOG_DIR"/audit_*.jsonl | head +tail -n 20 "$LOG_DIR"/audit_*.jsonl 2>/dev/null | tail -n 20 +``` + +**Solution:** +- Ensure all wrapper processes use the same `audit.log_dir` (via shared `--web-ui-config`). +- Restart stale processes so the active dashboard serves current code/config. +- Re-test by issuing a tool call, then refresh `/api/audit` and `/api/sessions`. -**Note:** Multiple wrapper processes can run simultaneously on *different* ports. Make sure you identify the PID bound specifically to the port you want, not just any `mcpbridge` process. +**Note:** Session-duration ordering edge cases are tracked separately in `BUG-T20`. ## Error: "Uptime still shows 1h 0m 0s" or behavior is unchanged after upgrade diff --git a/Sources/XcodeMCPWrapper/Documentation.docc/WebUIDashboard.md b/Sources/XcodeMCPWrapper/Documentation.docc/WebUIDashboard.md index 775cf0ac..61d9e781 100644 --- a/Sources/XcodeMCPWrapper/Documentation.docc/WebUIDashboard.md +++ b/Sources/XcodeMCPWrapper/Documentation.docc/WebUIDashboard.md @@ -101,7 +101,7 @@ fall back to their defaults. | `metrics.max_datapoints` | Max data points per series | `3600` | | `metrics.capture_params` | Record parameter key names per tool call for pattern analysis | `false` | | `audit.enabled` | Enable audit logging | `true` | -| `audit.log_dir` | Audit log directory | `logs/audit` | +| `audit.log_dir` | Audit log directory (relative paths resolve from the config-file directory; otherwise from current process working directory) | `logs/audit` | | `audit.max_file_size_mb` | Max log file size | `10.0` | | `audit.max_files` | Max rotated log files | `10` | | `audit.capture_payload` | Capture full request/response payloads in the ring buffer | `false` | @@ -150,6 +150,20 @@ Table showing Avg / P50 / P95 / P99 / Min / Max latency per tool. Paginated table of recent tool calls with timestamp, tool name, direction, request ID, latency, and error message. Supports filter by tool name, JSON export, and CSV export. +### Multi-Process Consistency Model + +When multiple wrapper processes write to the same audit log directory (for example, frequent +Cursor reconnects), the dashboard uses this model: + +- Audit data is shared through on-disk JSONL files in `audit.log_dir`. +- `/api/audit` refreshes from those files when they change, so entries from sibling processes + become visible without restarting the dashboard process. +- `/api/sessions` is computed from the same refreshed audit entry set used by `/api/audit`. +- Tool charts/KPIs are sourced from `SharedMetricsStore` (SQLite) and remain process-shared. + +Known limitation: +- Session ordering/duration edge cases are tracked separately under `BUG-T20`. + ## API Endpoints | Endpoint | Method | Description | diff --git a/docs/data-storage.md b/docs/data-storage.md index 72d3178f..b6eabdc5 100644 --- a/docs/data-storage.md +++ b/docs/data-storage.md @@ -205,7 +205,7 @@ Data is lost on process exit — there is no persistence. - **Encoding:** UTF-8 newline-delimited JSON (`.jsonl`) - **File naming:** `audit_YYYYMMDD_HHMMSS.jsonl` (UTC timestamp) -- **Default directory:** `logs/audit/` relative to the working directory; configurable via `AuditLogger(log_dir=...)`. +- **Default directory:** `logs/audit/` (normalized to an absolute path at startup). Relative values resolve from the `--web-ui-config` file directory when provided, otherwise from the process working directory. - **Rotation:** A new file is opened when the current file exceeds `max_file_size_mb` (default 10 MB). Up to `max_files` (default 10) rotated files are retained; the oldest is deleted when the limit is exceeded. - **Startup load:** At initialisation, all `audit_*.jsonl` files in `log_dir` are read in chronological order. The most recent 10 000 entries are loaded into `_entries` in memory so the Web UI dashboard can display history from sibling processes. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 34dd6eb0..d1f7a765 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -275,16 +275,57 @@ Both commands show the PID in the second column (`PID`). **Recovery:** ```bash -# Kill the stale process by PID -kill +# Kill the listener bound to the port +PORT=8080 +PID=$(lsof -tiTCP:$PORT -sTCP:LISTEN | head -n1) +kill "$PID" -# Or kill all wrapper/bridge processes in one step -pkill -f mcpbridge +# If it survives (some builds do not exit on SIGTERM), force kill +sleep 0.5 +ps -p "$PID" >/dev/null 2>&1 && kill -9 "$PID" + +# Optionally clear other web-ui wrapper instances (case-insensitive pattern) +pkill -f -i "mcpbridge_wrapper --web-ui" || true + +# Verify the port is now free (expected: no output) +lsof -nP -iTCP:$PORT -sTCP:LISTEN ``` After stopping the stale process, restart your MCP client (Cursor / Zed / Claude Code) or re-run the `--web-ui-only` command and the port should now be free. +Prefer `kill` (`SIGTERM`) first; use `kill -9` only when the process does not exit. + +**Note:** Multiple wrapper processes can run simultaneously on *different* ports. Make sure you identify the PID bound specifically to the port you want, not just any `mcpbridge` process. If the port is immediately re-occupied, close/restart MCP clients (Cursor/Zed/Claude) that may auto-spawn a new wrapper process. + +--- + +### "Tool charts are fresh, but Audit Log / Session Timeline look stale" + +**Symptom:** Chart widgets (request counts, tool distribution) show new activity, but `/api/audit` and Session Timeline still show older entries. + +**Cause:** Multi-process client reconnects can split writes across wrapper processes. Audit/session views depend on shared JSONL audit files in `audit.log_dir`; if processes are writing to different log directories or an outdated runtime is still serving UI, views can appear stale. + +**Diagnosis:** + +```bash +# 1) Verify active dashboard process and port +PORT=8080 +lsof -i TCP:$PORT -sTCP:LISTEN + +# 2) Check audit log directory configured in that process +curl -s http://127.0.0.1:$PORT/api/config | jq '.audit.log_dir' + +# 3) Inspect recent shared audit entries on disk +LOG_DIR=$(curl -s http://127.0.0.1:$PORT/api/config | jq -r '.audit.log_dir') +ls -lt "$LOG_DIR"/audit_*.jsonl | head +tail -n 20 "$LOG_DIR"/audit_*.jsonl 2>/dev/null | tail -n 20 +``` + +**Solution:** +- Ensure all wrapper processes use the same `audit.log_dir` (via shared `--web-ui-config`). +- Restart stale processes so the active dashboard serves current code/config. +- Re-test by issuing a tool call, then refresh `/api/audit` and `/api/sessions`. -**Note:** Multiple wrapper processes can run simultaneously on *different* ports. Make sure you identify the PID bound specifically to the port you want, not just any `mcpbridge` process. +**Note:** Session-duration ordering edge cases are tracked separately in `BUG-T20`. --- diff --git a/docs/webui-setup.md b/docs/webui-setup.md index 17c79479..df69bdf2 100644 --- a/docs/webui-setup.md +++ b/docs/webui-setup.md @@ -133,7 +133,7 @@ Create a `webui.json` configuration file: | `metrics.max_datapoints` | Max data points per series | `3600` | | `metrics.capture_params` | Record parameter key names per tool call for pattern analysis | `false` | | `audit.enabled` | Enable audit logging | `true` | -| `audit.log_dir` | Audit log directory | `logs/audit` | +| `audit.log_dir` | Audit log directory (relative paths resolve from the config-file directory; otherwise from current process working directory) | `logs/audit` | | `audit.max_file_size_mb` | Max log file size | `10.0` | | `audit.max_files` | Max rotated log files | `10` | | `audit.capture_payload` | Capture full request/response payloads in the ring buffer | `false` | @@ -223,6 +223,18 @@ Features: - **Export JSON**: Download full audit log as JSON - **Export CSV**: Download as CSV for spreadsheet analysis +### Multi-Process Consistency Model + +When multiple wrapper processes write to the same audit log directory (for example, frequent Cursor reconnects), the dashboard uses this model: + +- Audit data is shared through on-disk JSONL files in `audit.log_dir`. +- `/api/audit` refreshes from those files when they change, so entries from sibling processes become visible without restarting the dashboard process. +- `/api/sessions` is computed from the same refreshed audit entry set used by `/api/audit`. +- Tool charts/KPIs are sourced from `SharedMetricsStore` (SQLite) and remain process-shared. + +Known limitation: +- Session ordering/duration edge cases are tracked separately under `BUG-T20`. + ## API Endpoints The Web UI exposes a REST API: diff --git a/pyproject.toml b/pyproject.toml index ffc3c7da..b4804626 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dev = [ "pytest>=7.0", "pytest-cov>=4.0", "pytest-asyncio>=0.21", - "ruff>=0.1.0", + "ruff==0.15.2", "mypy>=1.0", ] webui = [ diff --git a/scripts/check_doc_sync.py b/scripts/check_doc_sync.py index 47c3ea32..1496f2c9 100755 --- a/scripts/check_doc_sync.py +++ b/scripts/check_doc_sync.py @@ -10,6 +10,8 @@ python scripts/check_doc_sync.py --staged # Check staged changes python scripts/check_doc_sync.py --branch # Check branch changes (CI) python scripts/check_doc_sync.py --all # Check unstaged, staged, and branch changes + python scripts/check_doc_sync.py --all --require-same-commit + # Branch scope: require docs/ and mapped DocC file to be changed in the same commit Exit codes: 0 - All docs are synced or no docs changed @@ -49,6 +51,14 @@ def _run_git_name_only(args: List[str]) -> Optional[Set[str]]: return set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() +def _run_git_lines(args: List[str]) -> Optional[List[str]]: + """Run git command and return non-empty output lines, or None if it fails.""" + result = subprocess.run(args, capture_output=True, text=True) + if result.returncode != 0: + return None + return [line for line in result.stdout.splitlines() if line.strip()] + + def _get_untracked_files() -> Set[str]: """Return new untracked files (not yet staged or committed).""" result = subprocess.run( @@ -71,6 +81,14 @@ def _ref_exists(ref: str) -> bool: return result.returncode == 0 +def _resolve_branch_base_ref() -> Optional[str]: + """Return the preferred branch base ref for comparisons.""" + for base_ref in ("origin/main", "main", "origin/master", "master"): + if _ref_exists(base_ref): + return base_ref + return None + + def get_changed_files(mode: str = "unstaged") -> Set[str]: """Get list of changed files from git.""" if mode == "staged": @@ -79,10 +97,8 @@ def get_changed_files(mode: str = "unstaged") -> Set[str]: if mode == "branch": # Prefer remote-tracking main (CI), then local main/master fallback. - for base_ref in ("origin/main", "main", "origin/master", "master"): - if not _ref_exists(base_ref): - continue - + base_ref = _resolve_branch_base_ref() + if base_ref is not None: changed = _run_git_name_only(["git", "diff", "--name-only", f"{base_ref}...HEAD"]) if changed is not None: return changed @@ -106,25 +122,67 @@ def get_changed_files(mode: str = "unstaged") -> Set[str]: return (changed if changed is not None else set()) | _get_untracked_files() -def run_check_for_mode(mode: str) -> bool: - """Run DocC sync check for a single change mode.""" - print(f"Checking {mode} changes for DocC sync...\n") - - changed_files = get_changed_files(mode) - if not changed_files: - print("No files changed") +def check_doc_sync_same_commit(changed_files: Set[str]) -> bool: + """Strict check: docs and mapped DocC must change together in at least one commit.""" + filtered_files = changed_files - OUT_OF_SCOPE_DOCS + docs_changed = {file for file in filtered_files if file in DOC_MAPPING} + if not docs_changed: + print("✓ No documentation changes detected") return True - return check_doc_sync(changed_files) + base_ref = _resolve_branch_base_ref() + if base_ref is None: + print( + "Warning: could not find main/master ref; " + "strict same-commit check falls back to HEAD only." + ) + commits = ["HEAD"] + else: + commits = _run_git_lines(["git", "rev-list", "--reverse", f"{base_ref}..HEAD"]) + if commits is None: + print("⚠ WARNING: unable to enumerate branch commits for strict same-commit check") + return False + if not commits: + commits = ["HEAD"] + + paired_docs: Set[str] = set() + for commit in commits: + commit_files = _run_git_name_only( + ["git", "show", "--pretty=format:", "--name-only", commit] + ) + if commit_files is None: + print(f"⚠ WARNING: unable to inspect changed files for commit {commit}") + return False + for doc in docs_changed: + if doc in commit_files and DOC_MAPPING[doc] in commit_files: + paired_docs.add(doc) -def run_all_modes() -> bool: + unsynced = sorted(docs_changed - paired_docs) + if unsynced: + print( + f"\n⚠ WARNING: {len(unsynced)} docs file(s) were not updated in the same commit " + "as their DocC mirror:" + ) + for doc in unsynced: + print(f" - {doc} ↔ {DOC_MAPPING[doc]}") + print( + "\nStrict mode requires at least one commit where each docs/ file and its mapped " + "DocC file change together." + ) + return False + + print("\n✓ Strict same-commit DocC sync check passed") + return True + + +def run_all_modes(require_same_commit: bool = False) -> bool: """Run DocC sync checks for unstaged, staged, and branch change scopes.""" all_passed = True for mode in ALL_MODES: print(f"=== Mode: {mode} ===") - mode_passed = run_check_for_mode(mode) + mode_passed = run_check_for_mode(mode, require_same_commit=require_same_commit) all_passed = all_passed and mode_passed print() @@ -181,6 +239,24 @@ def check_doc_sync(changed_files: Set[str]) -> bool: return True +def run_check_for_mode(mode: str, require_same_commit: bool = False) -> bool: + """Run DocC sync check for a single change mode.""" + print(f"Checking {mode} changes for DocC sync...\n") + + changed_files = get_changed_files(mode) + if not changed_files: + print("No files changed") + return True + + if not check_doc_sync(changed_files): + return False + + if require_same_commit and mode == "branch": + return check_doc_sync_same_commit(changed_files) + + return True + + def main() -> int: """Parse arguments and execute DocC sync checks.""" import argparse @@ -209,6 +285,14 @@ def main() -> int: action="store_true", help="Skip the check (for PRs that intentionally only change docs/)", ) + parser.add_argument( + "--require-same-commit", + action="store_true", + help=( + "Require each changed docs/ file and its mapped DocC file to be updated " + "in at least one shared commit (branch mode only)" + ), + ) args = parser.parse_args() @@ -217,10 +301,10 @@ def main() -> int: return 0 if args.all: - return 0 if run_all_modes() else 1 + return 0 if run_all_modes(require_same_commit=args.require_same_commit) else 1 mode = "branch" if args.branch else ("staged" if args.staged else "unstaged") - return 0 if run_check_for_mode(mode) else 1 + return 0 if run_check_for_mode(mode, require_same_commit=args.require_same_commit) else 1 if __name__ == "__main__": diff --git a/src/mcpbridge_wrapper/webui/audit.py b/src/mcpbridge_wrapper/webui/audit.py index 115c4ada..b66dcdb1 100644 --- a/src/mcpbridge_wrapper/webui/audit.py +++ b/src/mcpbridge_wrapper/webui/audit.py @@ -12,7 +12,7 @@ import threading import time from collections import OrderedDict -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple class AuditLogger: @@ -56,6 +56,7 @@ def __init__( self._max_memory_entries = 10000 self._enabled = True self._capture_payload = capture_payload + self._history_signature: Tuple[Tuple[str, int, int], ...] = () # OrderedDict preserves insertion order; oldest entry evicted when full. self._payload_buffer: OrderedDict[str, Dict[str, Any]] = OrderedDict() @@ -63,26 +64,44 @@ def __init__( self._open_log_file() self._load_history() - def _load_history(self) -> None: - """Load existing JSONL entries from log_dir into memory at startup. + def _log_files_snapshot(self) -> Tuple[Tuple[str, int, int], ...]: + """Return sorted audit log file metadata used for change detection. - Reads all ``audit_*.jsonl`` files in chronological order and populates - ``self._entries`` with the most-recent ``_max_memory_entries`` entries. - Malformed lines are silently skipped. This gives the web UI dashboard - visibility into entries written by sibling processes in multi-process - setups (Cursor, Zed) where each client connection spawns a fresh wrapper. + Each tuple entry is ``(filename, size_bytes, mtime_ns)``. Any change in + this snapshot means on-disk history changed and should be reloaded. """ try: - files = sorted( - f - for f in os.listdir(self._log_dir) - if f.startswith("audit_") and f.endswith(".jsonl") - ) + snapshot: List[Tuple[str, int, int]] = [] + for filename in os.listdir(self._log_dir): + if not (filename.startswith("audit_") and filename.endswith(".jsonl")): + continue + path = os.path.join(self._log_dir, filename) + try: + stat = os.stat(path) + except OSError: + continue + mtime_ns = int(getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000))) + snapshot.append((filename, int(stat.st_size), mtime_ns)) + snapshot.sort(key=lambda item: item[0]) + return tuple(snapshot) except OSError: + return () + + def _load_history(self, force: bool = False) -> None: + """Load or refresh existing JSONL entries from ``log_dir``. + + Reads all ``audit_*.jsonl`` files in chronological order and populates + ``self._entries`` with the most-recent ``_max_memory_entries`` entries. + Malformed lines are silently skipped. Reloading is skipped unless file + metadata changes (or ``force=True``), which keeps reads cheap while + preserving visibility into entries written by sibling processes. + """ + snapshot = self._log_files_snapshot() + if not force and snapshot == self._history_signature: return raw_lines: List[str] = [] - for filename in files: + for filename, _size, _mtime_ns in snapshot: path = os.path.join(self._log_dir, filename) try: with open(path, encoding="utf-8") as fh: @@ -107,6 +126,41 @@ def _load_history(self) -> None: continue self._entries = entries + self._history_signature = snapshot + + def _update_history_signature_after_local_write(self) -> None: + """Advance cached history signature after this process appends a log. + + This avoids triggering a full history reload on the next read endpoint + call when only local writes happened since the previous signature. + """ + if self._current_path is None: + return + + # Start from known entries that still exist. + signature_map: Dict[str, Tuple[int, int]] = {} + for filename, size, mtime_ns in self._history_signature: + path = os.path.join(self._log_dir, filename) + if os.path.exists(path): + signature_map[filename] = (size, mtime_ns) + + try: + stat = os.stat(self._current_path) + except OSError: + self._history_signature = tuple( + sorted( + (filename, size, mtime_ns) + for filename, (size, mtime_ns) in signature_map.items() + ) + ) + return + + filename = os.path.basename(self._current_path) + mtime_ns = int(getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000))) + signature_map[filename] = (int(stat.st_size), mtime_ns) + self._history_signature = tuple( + sorted((name, size, mtime) for name, (size, mtime) in signature_map.items()) + ) def _log_filename(self) -> str: """Generate a timestamped log filename. @@ -233,6 +287,7 @@ def log( if self._current_file is not None: self._current_file.write(json.dumps(entry, separators=(",", ":")) + "\n") self._current_file.flush() + self._update_history_signature_after_local_write() # Keep in memory for dashboard self._entries.append(entry) @@ -267,7 +322,8 @@ def get_entries( List of audit log entries, most recent first. """ with self._lock: - entries = self._entries + self._load_history() + entries = list(self._entries) if tool_filter: entries = [e for e in entries if e.get("tool") == tool_filter] # Reverse for most-recent-first @@ -284,7 +340,8 @@ def export_json(self, limit: Optional[int] = None) -> str: JSON string of audit log entries. """ with self._lock: - entries = self._entries + self._load_history() + entries = list(self._entries) if limit is not None: entries = entries[-limit:] return json.dumps(entries, indent=2) @@ -299,7 +356,8 @@ def export_csv(self, limit: Optional[int] = None) -> str: CSV string of audit log entries. """ with self._lock: - entries = self._entries + self._load_history() + entries = list(self._entries) if limit is not None: entries = entries[-limit:] @@ -329,6 +387,7 @@ def get_entry_count(self) -> int: Number of entries in memory. """ with self._lock: + self._load_history() return len(self._entries) def get_payload(self, request_id: str) -> Optional[Dict[str, Any]]: diff --git a/src/mcpbridge_wrapper/webui/config.py b/src/mcpbridge_wrapper/webui/config.py index 44731edd..dd5adf78 100644 --- a/src/mcpbridge_wrapper/webui/config.py +++ b/src/mcpbridge_wrapper/webui/config.py @@ -55,9 +55,12 @@ def __init__(self, config_path: Optional[str] = None) -> None: config_path: Optional path to JSON config file. """ self._data: Dict[str, Any] = json.loads(json.dumps(_DEFAULTS)) + config_dir: Optional[str] = None if config_path and os.path.isfile(config_path): - with open(config_path, encoding="utf-8") as f: + resolved_config_path = os.path.abspath(config_path) + config_dir = os.path.dirname(resolved_config_path) + with open(resolved_config_path, encoding="utf-8") as f: user_config = json.load(f) self._merge(self._data, user_config) @@ -82,6 +85,8 @@ def __init__(self, config_path: Optional[str] = None) -> None: if env_pass: self._data["auth"]["password"] = env_pass + self._normalize_paths(config_dir=config_dir) + @staticmethod def _merge(base: Dict[str, Any], override: Dict[str, Any]) -> None: """Recursively merge override dict into base dict. @@ -96,6 +101,16 @@ def _merge(base: Dict[str, Any], override: Dict[str, Any]) -> None: else: base[key] = value + def _normalize_paths(self, config_dir: Optional[str]) -> None: + """Normalize configured filesystem paths to deterministic absolute values.""" + raw_log_dir = str(self._data["audit"]["log_dir"]) + if os.path.isabs(raw_log_dir): + normalized = os.path.normpath(raw_log_dir) + else: + base_dir = config_dir if config_dir is not None else os.getcwd() + normalized = os.path.abspath(os.path.join(base_dir, raw_log_dir)) + self._data["audit"]["log_dir"] = normalized + @property def host(self) -> str: """Server bind host address.""" diff --git a/src/mcpbridge_wrapper/webui/server.py b/src/mcpbridge_wrapper/webui/server.py index 7bff7523..a56a5538 100644 --- a/src/mcpbridge_wrapper/webui/server.py +++ b/src/mcpbridge_wrapper/webui/server.py @@ -47,6 +47,19 @@ _STATIC_DIR = os.path.join(os.path.dirname(__file__), "static") +def _entry_timestamp(entry: dict[str, Any]) -> float: + """Return a sortable timestamp value for a session entry.""" + try: + return float(entry.get("timestamp", 0.0)) + except (TypeError, ValueError): + return 0.0 + + +def _sorted_session_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Normalize audit entries to chronological order for session detection.""" + return sorted(entries, key=_entry_timestamp) + + def is_port_available(host: str, port: int) -> bool: """Check whether *host:port* is available for binding. @@ -297,7 +310,7 @@ async def get_sessions( """Get tool call sessions grouped by idle gap.""" _check_auth(request, config) effective_gap = gap_seconds if gap_seconds is not None else config.session_gap_seconds - entries = audit.get_entries(limit=limit) + entries = _sorted_session_entries(audit.get_entries(limit=limit)) sessions = detect_sessions(entries, gap_seconds=float(effective_gap)) return {"sessions": sessions, "total": len(sessions)} @@ -346,7 +359,7 @@ async def ws_metrics(websocket: WebSocket) -> None: # Send metrics every refresh interval summary = metrics.get_summary() timeseries = metrics.get_timeseries(config.chart_history_seconds) - entries = audit.get_entries(limit=10000) + entries = _sorted_session_entries(audit.get_entries(limit=10000)) sessions = detect_sessions(entries, gap_seconds=float(config.session_gap_seconds)) await websocket.send_json( { diff --git a/tests/integration/webui/test_e2e.py b/tests/integration/webui/test_e2e.py index 38efa03b..10e3afa5 100644 --- a/tests/integration/webui/test_e2e.py +++ b/tests/integration/webui/test_e2e.py @@ -175,6 +175,29 @@ def test_audit_export_with_filtering(self, setup): assert "XcodeRead" in csv_text assert "XcodeWrite" in csv_text + def test_multi_process_audit_and_sessions_consistency(self, setup): + """Audit and sessions endpoints pick up sibling-process log writes.""" + client, config, metrics, audit = setup + + sibling = AuditLogger(log_dir=config.audit_log_dir) + sibling.log("XcodeListWindows", request_id="ext-init", direction="response") + sibling.close() + + response = client.get("/api/audit?limit=50") + assert response.status_code == 200 + entries = response.json()["entries"] + assert any(entry.get("request_id") == "ext-init" for entry in entries) + + response = client.get("/api/sessions?limit=50") + assert response.status_code == 200 + sessions = response.json()["sessions"] + found = any( + tool.get("request_id") == "ext-init" + for session in sessions + for tool in session.get("tools", []) + ) + assert found + def test_concurrent_requests_simulation(self, setup): """Test simulating concurrent requests.""" client, config, metrics, audit = setup diff --git a/tests/test_check_doc_sync.py b/tests/test_check_doc_sync.py index 3ab6fe54..77c1e9e9 100644 --- a/tests/test_check_doc_sync.py +++ b/tests/test_check_doc_sync.py @@ -125,3 +125,69 @@ def fake_get_changed_files(mode: str): assert exit_code == 0 assert observed_modes == ["unstaged", "staged", "branch"] + + +def test_check_doc_sync_same_commit_detects_split_updates(monkeypatch) -> None: + """Strict mode fails when docs and DocC changes happen in separate commits.""" + module = load_script_module() + mapped = "Sources/XcodeMCPWrapper/Documentation.docc/Installation.md" + + monkeypatch.setattr(module, "_resolve_branch_base_ref", lambda: "origin/main") + + def fake_run_git_lines(args: list[str]) -> list[str] | None: + if args == ["git", "rev-list", "--reverse", "origin/main..HEAD"]: + return ["c1", "c2"] + return None + + def fake_run_git_name_only(args: list[str]) -> set[str] | None: + if args == ["git", "show", "--pretty=format:", "--name-only", "c1"]: + return {"docs/installation.md"} + if args == ["git", "show", "--pretty=format:", "--name-only", "c2"]: + return {mapped} + return set() + + monkeypatch.setattr(module, "_run_git_lines", fake_run_git_lines) + monkeypatch.setattr(module, "_run_git_name_only", fake_run_git_name_only) + + changed_files = {"docs/installation.md", mapped} + assert module.check_doc_sync_same_commit(changed_files) is False + + +def test_check_doc_sync_same_commit_accepts_paired_update(monkeypatch) -> None: + """Strict mode passes when docs and DocC are changed in the same commit.""" + module = load_script_module() + mapped = "Sources/XcodeMCPWrapper/Documentation.docc/Installation.md" + + monkeypatch.setattr(module, "_resolve_branch_base_ref", lambda: "origin/main") + monkeypatch.setattr( + module, + "_run_git_lines", + lambda args: ( + ["c1"] if args == ["git", "rev-list", "--reverse", "origin/main..HEAD"] else None + ), + ) + monkeypatch.setattr( + module, + "_run_git_name_only", + lambda args: ( + {"docs/installation.md", mapped} + if args == ["git", "show", "--pretty=format:", "--name-only", "c1"] + else set() + ), + ) + + changed_files = {"docs/installation.md", mapped} + assert module.check_doc_sync_same_commit(changed_files) is True + + +def test_run_check_for_mode_branch_honors_strict_flag(monkeypatch) -> None: + """Branch mode applies strict same-commit validation when requested.""" + module = load_script_module() + mapped = "Sources/XcodeMCPWrapper/Documentation.docc/Installation.md" + + monkeypatch.setattr(module, "get_changed_files", lambda mode: {"docs/installation.md", mapped}) + monkeypatch.setattr(module, "check_doc_sync", lambda _: True) + monkeypatch.setattr(module, "check_doc_sync_same_commit", lambda _: False) + + assert module.run_check_for_mode("branch", require_same_commit=True) is False + assert module.run_check_for_mode("branch", require_same_commit=False) is True diff --git a/tests/unit/webui/test_audit.py b/tests/unit/webui/test_audit.py index 60f41707..18564b04 100644 --- a/tests/unit/webui/test_audit.py +++ b/tests/unit/webui/test_audit.py @@ -5,6 +5,7 @@ import json import os import tempfile +from unittest.mock import patch from mcpbridge_wrapper.webui.audit import AuditLogger @@ -255,7 +256,7 @@ def test_startup_respects_max_memory_entries(self): audit_b._max_memory_entries = 50 # override cap for test # Re-run the load with the new cap. audit_b._entries = [] - audit_b._load_history() + audit_b._load_history(force=True) assert audit_b.get_entry_count() <= 50 audit_b.close() @@ -302,6 +303,39 @@ def test_startup_multiple_files_chronological_order(self): assert old_idx < new_idx audit.close() + def test_read_paths_refresh_external_updates_without_restart(self): + """Read APIs reload shared JSONL history after sibling-process writes.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Simulate web-serving process. + reader = AuditLogger(log_dir=tmpdir) + assert reader.get_entry_count() == 0 + + # Simulate sibling process logging new traffic. + writer = AuditLogger(log_dir=tmpdir) + writer.log("XcodeListWindows", request_id="ext-1", direction="response") + writer.close() + + entries = reader.get_entries(limit=10) + assert any(e.get("request_id") == "ext-1" for e in entries) + assert reader.get_entry_count() >= 1 + + exported = json.loads(reader.export_json()) + assert any(e.get("request_id") == "ext-1" for e in exported) + reader.close() + + def test_local_write_updates_signature_to_avoid_full_reparse(self): + """A local append should not force _load_history() to re-open all files.""" + with tempfile.TemporaryDirectory() as tmpdir: + audit = AuditLogger(log_dir=tmpdir) + audit.log("XcodeRead", request_id="local-1", direction="response") + + # When signature is current, _load_history() should return early + # and never attempt to open JSONL files. + with patch("builtins.open", side_effect=AssertionError("unexpected reparse")): + audit._load_history() + + audit.close() + class TestPayloadCapture: """Tests for the payload ring buffer feature.""" diff --git a/tests/unit/webui/test_config.py b/tests/unit/webui/test_config.py index edc13a76..e339fe93 100644 --- a/tests/unit/webui/test_config.py +++ b/tests/unit/webui/test_config.py @@ -22,7 +22,7 @@ def test_default_values(self): assert config.metrics_window_seconds == 3600 assert config.metrics_max_datapoints == 3600 assert config.audit_enabled is True - assert config.audit_log_dir == "logs/audit" + assert config.audit_log_dir == os.path.abspath("logs/audit") assert config.audit_max_file_size_mb == 10.0 assert config.audit_max_files == 10 assert config.dashboard_refresh_interval_ms == 1000 @@ -58,6 +58,29 @@ def test_config_merge_nested(self): finally: os.unlink(temp_path) + def test_relative_audit_log_dir_resolved_from_config_file_directory(self): + """Relative audit.log_dir is resolved from the config file directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_dir = os.path.join(tmpdir, "cfg") + os.makedirs(config_dir, exist_ok=True) + config_path = os.path.join(config_dir, "webui.json") + with open(config_path, "w", encoding="utf-8") as f: + json.dump({"audit": {"log_dir": "logs/audit"}}, f) + + config = WebUIConfig(config_path=config_path) + assert config.audit_log_dir == os.path.join(config_dir, "logs", "audit") + + def test_absolute_audit_log_dir_preserved(self): + """Absolute audit.log_dir remains unchanged.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_path = os.path.join(tmpdir, "webui.json") + absolute_log_dir = os.path.join(tmpdir, "abs-audit") + with open(config_path, "w", encoding="utf-8") as f: + json.dump({"audit": {"log_dir": absolute_log_dir}}, f) + + config = WebUIConfig(config_path=config_path) + assert config.audit_log_dir == absolute_log_dir + def test_env_override_host(self): """Test environment variable override for host.""" with patch.dict(os.environ, {"WEBUI_HOST": "192.168.1.1"}): diff --git a/tests/unit/webui/test_server.py b/tests/unit/webui/test_server.py index b44911dd..0cd9fc7f 100644 --- a/tests/unit/webui/test_server.py +++ b/tests/unit/webui/test_server.py @@ -123,6 +123,57 @@ def test_get_audit_logs_with_filter(self, client, audit): for entry in data["entries"]: assert entry["tool"] == "XcodeRead" + def test_audit_and_sessions_refresh_shared_history(self, client, audit): + """Audit and sessions endpoints include sibling-process writes.""" + sibling = AuditLogger(log_dir=audit._log_dir) + sibling.log("BuildProject", request_id="external-req", direction="response") + sibling.close() + + response = client.get("/api/audit?limit=50") + assert response.status_code == 200 + entries = response.json()["entries"] + assert any(entry.get("request_id") == "external-req" for entry in entries) + + response = client.get("/api/sessions?limit=50") + assert response.status_code == 200 + sessions = response.json()["sessions"] + found = any( + tool.get("request_id") == "external-req" + for session in sessions + for tool in session.get("tools", []) + ) + assert found + + def test_sessions_endpoint_sorts_entries_chronologically(self, client, audit, monkeypatch): + """Sessions API normalizes reverse-chronological audit rows before grouping.""" + reverse_entries = [ + { + "timestamp": 200.0, + "timestamp_iso": "2026-02-25T10:00:00Z", + "tool": "SecondTool", + "request_id": "req-2", + "direction": "response", + }, + { + "timestamp": 100.0, + "timestamp_iso": "2026-02-25T09:58:20Z", + "tool": "FirstTool", + "request_id": "req-1", + "direction": "response", + }, + ] + + monkeypatch.setattr(audit, "get_entries", lambda *args, **kwargs: reverse_entries) + + response = client.get("/api/sessions?limit=2") + assert response.status_code == 200 + sessions = response.json()["sessions"] + assert len(sessions) == 1 + session = sessions[0] + assert session["start"] == 100.0 + assert session["end"] == 200.0 + assert [t["request_id"] for t in session["tools"]] == ["req-1", "req-2"] + def test_export_audit_json(self, client, audit): """Test exporting audit as JSON.""" audit.log("XcodeRead") @@ -275,6 +326,34 @@ def test_websocket_metrics_update_includes_sessions(self, client, audit): assert "sessions" in message assert isinstance(message["sessions"], list) + def test_websocket_sessions_are_sorted_chronologically(self, client, audit, monkeypatch): + """WebSocket payload session windows use non-decreasing start/end timestamps.""" + reverse_entries = [ + { + "timestamp": 300.0, + "timestamp_iso": "2026-02-25T10:03:00Z", + "tool": "LatestTool", + "request_id": "req-3", + "direction": "response", + }, + { + "timestamp": 100.0, + "timestamp_iso": "2026-02-25T10:01:00Z", + "tool": "EarlierTool", + "request_id": "req-1", + "direction": "response", + }, + ] + + monkeypatch.setattr(audit, "get_entries", lambda *args, **kwargs: reverse_entries) + + with client.websocket_connect("/ws/metrics") as websocket: + message = websocket.receive_json() + + sessions = message["sessions"] + assert sessions + assert sessions[0]["start"] <= sessions[0]["end"] + class TestAuth: """Test authentication."""