@bitseek/hermes-webui 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +213 -0
- package/bin/hermes-webui.mjs +588 -0
- package/package.json +25 -0
- package/scripts/sync-vendor.mjs +74 -0
- package/templates/launchd/com.bitseek.hermes-webui.plist +21 -0
- package/templates/systemd/hermes-webui.service +13 -0
- package/templates/windows/hermes-webui-task.ps1 +3 -0
- package/vendor/agent-frontend-shell/.bitseek-source.json +6 -0
- package/vendor/agent-frontend-shell/.dockerignore +7 -0
- package/vendor/agent-frontend-shell/.env.docker.example +89 -0
- package/vendor/agent-frontend-shell/.env.example +34 -0
- package/vendor/agent-frontend-shell/.github/FUNDING.yml +3 -0
- package/vendor/agent-frontend-shell/.github/workflows/browser-smoke.yml +42 -0
- package/vendor/agent-frontend-shell/.github/workflows/docker-smoke.yml +233 -0
- package/vendor/agent-frontend-shell/.github/workflows/native-windows-startup.yml +132 -0
- package/vendor/agent-frontend-shell/.github/workflows/release.yml +57 -0
- package/vendor/agent-frontend-shell/.github/workflows/tests.yml +88 -0
- package/vendor/agent-frontend-shell/.vscode/launch.json +59 -0
- package/vendor/agent-frontend-shell/.vscode/settings.json +13 -0
- package/vendor/agent-frontend-shell/AGENTS.md +80 -0
- package/vendor/agent-frontend-shell/ARCHITECTURE.md +1658 -0
- package/vendor/agent-frontend-shell/BUGS.md +52 -0
- package/vendor/agent-frontend-shell/CHANGELOG.md +7295 -0
- package/vendor/agent-frontend-shell/CONTRIBUTING.md +205 -0
- package/vendor/agent-frontend-shell/CONTRIBUTORS.md +107 -0
- package/vendor/agent-frontend-shell/DESIGN.md +173 -0
- package/vendor/agent-frontend-shell/Dockerfile +91 -0
- package/vendor/agent-frontend-shell/LICENSE +21 -0
- package/vendor/agent-frontend-shell/README-CUSTOM.md +76 -0
- package/vendor/agent-frontend-shell/README.md +705 -0
- package/vendor/agent-frontend-shell/ROADMAP.md +351 -0
- package/vendor/agent-frontend-shell/SPRINTS.md +147 -0
- package/vendor/agent-frontend-shell/TESTING.md +1932 -0
- package/vendor/agent-frontend-shell/THEMES.md +170 -0
- package/vendor/agent-frontend-shell/api/__init__.py +1 -0
- package/vendor/agent-frontend-shell/api/agent_health.py +392 -0
- package/vendor/agent-frontend-shell/api/agent_sessions.py +782 -0
- package/vendor/agent-frontend-shell/api/auth.py +592 -0
- package/vendor/agent-frontend-shell/api/background.py +87 -0
- package/vendor/agent-frontend-shell/api/clarify.py +238 -0
- package/vendor/agent-frontend-shell/api/commands.py +124 -0
- package/vendor/agent-frontend-shell/api/compression_anchor.py +134 -0
- package/vendor/agent-frontend-shell/api/config.py +5178 -0
- package/vendor/agent-frontend-shell/api/dashboard_probe.py +255 -0
- package/vendor/agent-frontend-shell/api/extensions.py +253 -0
- package/vendor/agent-frontend-shell/api/gateway_chat.py +435 -0
- package/vendor/agent-frontend-shell/api/gateway_watcher.py +230 -0
- package/vendor/agent-frontend-shell/api/goals.py +608 -0
- package/vendor/agent-frontend-shell/api/helpers.py +474 -0
- package/vendor/agent-frontend-shell/api/kanban_bridge.py +1255 -0
- package/vendor/agent-frontend-shell/api/metering.py +194 -0
- package/vendor/agent-frontend-shell/api/models.py +4210 -0
- package/vendor/agent-frontend-shell/api/oauth.py +770 -0
- package/vendor/agent-frontend-shell/api/onboarding.py +1046 -0
- package/vendor/agent-frontend-shell/api/passkeys.py +365 -0
- package/vendor/agent-frontend-shell/api/profiles.py +1499 -0
- package/vendor/agent-frontend-shell/api/providers.py +2175 -0
- package/vendor/agent-frontend-shell/api/request_diagnostics.py +160 -0
- package/vendor/agent-frontend-shell/api/rollback.py +320 -0
- package/vendor/agent-frontend-shell/api/routes.py +13990 -0
- package/vendor/agent-frontend-shell/api/run_journal.py +284 -0
- package/vendor/agent-frontend-shell/api/runner_client.py +156 -0
- package/vendor/agent-frontend-shell/api/runtime_adapter.py +431 -0
- package/vendor/agent-frontend-shell/api/session_discoverability.py +640 -0
- package/vendor/agent-frontend-shell/api/session_events.py +45 -0
- package/vendor/agent-frontend-shell/api/session_lifecycle.py +208 -0
- package/vendor/agent-frontend-shell/api/session_ops.py +207 -0
- package/vendor/agent-frontend-shell/api/session_recovery.py +655 -0
- package/vendor/agent-frontend-shell/api/skill_usage.py +32 -0
- package/vendor/agent-frontend-shell/api/startup.py +128 -0
- package/vendor/agent-frontend-shell/api/state_sync.py +187 -0
- package/vendor/agent-frontend-shell/api/streaming.py +7048 -0
- package/vendor/agent-frontend-shell/api/system_health.py +167 -0
- package/vendor/agent-frontend-shell/api/terminal.py +410 -0
- package/vendor/agent-frontend-shell/api/turn_journal.py +214 -0
- package/vendor/agent-frontend-shell/api/updates.py +1261 -0
- package/vendor/agent-frontend-shell/api/upload.py +322 -0
- package/vendor/agent-frontend-shell/api/usage.py +26 -0
- package/vendor/agent-frontend-shell/api/workspace.py +867 -0
- package/vendor/agent-frontend-shell/api/workspace_git.py +1261 -0
- package/vendor/agent-frontend-shell/api/worktrees.py +357 -0
- package/vendor/agent-frontend-shell/bootstrap.py +492 -0
- package/vendor/agent-frontend-shell/ctl.sh +427 -0
- package/vendor/agent-frontend-shell/docker-compose.custom.yml +26 -0
- package/vendor/agent-frontend-shell/docker-compose.three-container.yml +168 -0
- package/vendor/agent-frontend-shell/docker-compose.two-container.yml +147 -0
- package/vendor/agent-frontend-shell/docker-compose.yml +57 -0
- package/vendor/agent-frontend-shell/docker_init.bash +459 -0
- package/vendor/agent-frontend-shell/docs/CONTRACTS.md +207 -0
- package/vendor/agent-frontend-shell/docs/EXTENSIONS.md +212 -0
- package/vendor/agent-frontend-shell/docs/ISSUES.md +23 -0
- package/vendor/agent-frontend-shell/docs/UIUX-GUIDE.md +196 -0
- package/vendor/agent-frontend-shell/docs/advanced-chat-setup.md +83 -0
- package/vendor/agent-frontend-shell/docs/docker.md +337 -0
- package/vendor/agent-frontend-shell/docs/onboarding-agent-checklist.md +207 -0
- package/vendor/agent-frontend-shell/docs/onboarding.md +202 -0
- package/vendor/agent-frontend-shell/docs/remote-access.md +75 -0
- package/vendor/agent-frontend-shell/docs/rfcs/README.md +53 -0
- package/vendor/agent-frontend-shell/docs/rfcs/agent-source-boundary.md +70 -0
- package/vendor/agent-frontend-shell/docs/rfcs/canonical-session-resolution.md +124 -0
- package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +1079 -0
- package/vendor/agent-frontend-shell/docs/rfcs/turn-journal.md +195 -0
- package/vendor/agent-frontend-shell/docs/rfcs/webui-run-state-consistency-contract.md +157 -0
- package/vendor/agent-frontend-shell/docs/supervisor.md +280 -0
- package/vendor/agent-frontend-shell/docs/troubleshooting.md +132 -0
- package/vendor/agent-frontend-shell/docs/ui-ux/index.html +863 -0
- package/vendor/agent-frontend-shell/docs/ui-ux/two-stage-proposal.html +768 -0
- package/vendor/agent-frontend-shell/docs/why-hermes.md +489 -0
- package/vendor/agent-frontend-shell/docs/workspace-git.md +92 -0
- package/vendor/agent-frontend-shell/docs/wsl-autostart.md +126 -0
- package/vendor/agent-frontend-shell/eslint.runtime-guard.config.mjs +35 -0
- package/vendor/agent-frontend-shell/extensions/bitseek-design-system.md +330 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/apple-touch-icon.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/empty-logo.svg +739 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-192.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-32.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.svg +745 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.ico +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.svg +745 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v2.svg +751 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v3.svg +739 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon.svg +745 -0
- package/vendor/agent-frontend-shell/extensions/branding/branding.js +112 -0
- package/vendor/agent-frontend-shell/extensions/branding/config.json +14 -0
- package/vendor/agent-frontend-shell/extensions/branding/manifest.json +53 -0
- package/vendor/agent-frontend-shell/extensions/index.js +67 -0
- package/vendor/agent-frontend-shell/extensions/loader/hermes-loader.js +77 -0
- package/vendor/agent-frontend-shell/extensions/manifest.json +16 -0
- package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.css +333 -0
- package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +487 -0
- package/vendor/agent-frontend-shell/extensions/pages/manifest.json +6 -0
- package/vendor/agent-frontend-shell/extensions/pages/registry.css +56 -0
- package/vendor/agent-frontend-shell/extensions/pages/registry.js +302 -0
- package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.css +93 -0
- package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.js +98 -0
- package/vendor/agent-frontend-shell/install.sh +63 -0
- package/vendor/agent-frontend-shell/mcp_server.py +567 -0
- package/vendor/agent-frontend-shell/package.json +12 -0
- package/vendor/agent-frontend-shell/pyproject.toml +56 -0
- package/vendor/agent-frontend-shell/pytest.ini +3 -0
- package/vendor/agent-frontend-shell/requirements.txt +5 -0
- package/vendor/agent-frontend-shell/server.py +624 -0
- package/vendor/agent-frontend-shell/start.ps1 +210 -0
- package/vendor/agent-frontend-shell/start.sh +65 -0
- package/vendor/agent-frontend-shell/static/apple-touch-icon.png +0 -0
- package/vendor/agent-frontend-shell/static/boot.js +1990 -0
- package/vendor/agent-frontend-shell/static/commands.js +1402 -0
- package/vendor/agent-frontend-shell/static/favicon-192.png +0 -0
- package/vendor/agent-frontend-shell/static/favicon-32.png +0 -0
- package/vendor/agent-frontend-shell/static/favicon-512.png +0 -0
- package/vendor/agent-frontend-shell/static/favicon-512.svg +18 -0
- package/vendor/agent-frontend-shell/static/favicon.ico +0 -0
- package/vendor/agent-frontend-shell/static/favicon.svg +20 -0
- package/vendor/agent-frontend-shell/static/i18n.js +15389 -0
- package/vendor/agent-frontend-shell/static/icons.js +92 -0
- package/vendor/agent-frontend-shell/static/index.html +1506 -0
- package/vendor/agent-frontend-shell/static/login.js +177 -0
- package/vendor/agent-frontend-shell/static/manifest.json +53 -0
- package/vendor/agent-frontend-shell/static/messages.js +3521 -0
- package/vendor/agent-frontend-shell/static/onboarding.js +800 -0
- package/vendor/agent-frontend-shell/static/panels.js +7995 -0
- package/vendor/agent-frontend-shell/static/pwa-startup.js +83 -0
- package/vendor/agent-frontend-shell/static/sessions.js +5165 -0
- package/vendor/agent-frontend-shell/static/style.css +4774 -0
- package/vendor/agent-frontend-shell/static/sw.js +173 -0
- package/vendor/agent-frontend-shell/static/terminal.js +632 -0
- package/vendor/agent-frontend-shell/static/ui.js +8997 -0
- package/vendor/agent-frontend-shell/static/vendor/js-yaml/4.1.0/js-yaml.min.js +2 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.css +1 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.js +1 -0
- package/vendor/agent-frontend-shell/static/vendor/smd.min.js +29 -0
- package/vendor/agent-frontend-shell/static/workspace.js +680 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
"""Read-only sidebar discoverability audit for Hermes WebUI sessions.
|
|
2
|
+
|
|
3
|
+
This module does not repair or mutate session state. It cross-checks the four
|
|
4
|
+
places that decide whether a session can be found from the WebUI sidebar:
|
|
5
|
+
|
|
6
|
+
- JSON sidecars under the WebUI session directory
|
|
7
|
+
- ``_index.json`` sidebar metadata
|
|
8
|
+
- canonical ``state.db`` rows/messages
|
|
9
|
+
- the live ``api.models.all_sessions()`` sidebar response, when available
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import sqlite3
|
|
18
|
+
from collections import Counter
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Iterable
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _safe_int(value, default: int = 0) -> int:
|
|
24
|
+
try:
|
|
25
|
+
if value is None:
|
|
26
|
+
return default
|
|
27
|
+
return int(value)
|
|
28
|
+
except (TypeError, ValueError):
|
|
29
|
+
return default
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read_json(path: Path):
|
|
33
|
+
try:
|
|
34
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
35
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _message_count_from_payload(payload: dict) -> int:
|
|
40
|
+
messages = payload.get("messages")
|
|
41
|
+
if isinstance(messages, list):
|
|
42
|
+
return len(messages)
|
|
43
|
+
return _safe_int(payload.get("message_count"), 0)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _record_from_mapping(mapping: dict, source_name: str) -> dict:
|
|
47
|
+
sid = str(mapping.get("session_id") or mapping.get("id") or "").strip()
|
|
48
|
+
if not sid:
|
|
49
|
+
return {}
|
|
50
|
+
return {
|
|
51
|
+
"session_id": sid,
|
|
52
|
+
"title": mapping.get("title"),
|
|
53
|
+
"message_count": _message_count_from_payload(mapping),
|
|
54
|
+
"source_tag": mapping.get("source_tag"),
|
|
55
|
+
"session_source": mapping.get("session_source"),
|
|
56
|
+
"source": mapping.get("source"),
|
|
57
|
+
"is_cli_session": mapping.get("is_cli_session"),
|
|
58
|
+
"parent_session_id": mapping.get("parent_session_id"),
|
|
59
|
+
"pre_compression_snapshot": bool(mapping.get("pre_compression_snapshot")),
|
|
60
|
+
"_lineage_root_id": mapping.get("_lineage_root_id"),
|
|
61
|
+
"archived": bool(mapping.get("archived")),
|
|
62
|
+
"project_id": mapping.get("project_id"),
|
|
63
|
+
"workspace": mapping.get("workspace"),
|
|
64
|
+
"_source_name": source_name,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _read_sidecars(session_dir: Path) -> dict[str, dict]:
|
|
69
|
+
records: dict[str, dict] = {}
|
|
70
|
+
if not session_dir.exists():
|
|
71
|
+
return records
|
|
72
|
+
for path in sorted(p for p in session_dir.glob("*.json") if not p.name.startswith("_")):
|
|
73
|
+
payload = _read_json(path)
|
|
74
|
+
if not isinstance(payload, dict):
|
|
75
|
+
continue
|
|
76
|
+
record = _record_from_mapping(payload, "sidecar")
|
|
77
|
+
if record:
|
|
78
|
+
records[record["session_id"]] = record
|
|
79
|
+
return records
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _read_index(session_dir: Path) -> dict[str, dict]:
|
|
83
|
+
payload = _read_json(session_dir / "_index.json")
|
|
84
|
+
records: dict[str, dict] = {}
|
|
85
|
+
if not isinstance(payload, list):
|
|
86
|
+
return records
|
|
87
|
+
for entry in payload:
|
|
88
|
+
if not isinstance(entry, dict):
|
|
89
|
+
continue
|
|
90
|
+
record = _record_from_mapping(entry, "index")
|
|
91
|
+
if record:
|
|
92
|
+
records[record["session_id"]] = record
|
|
93
|
+
return records
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _optional_expr(name: str, columns: set[str], fallback: str = "NULL") -> str:
|
|
97
|
+
return name if name in columns else f"{fallback} AS {name}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _read_state_db(state_db_path: Path | None) -> dict[str, dict]:
|
|
101
|
+
if state_db_path is None or not state_db_path.exists():
|
|
102
|
+
return {}
|
|
103
|
+
try:
|
|
104
|
+
with sqlite3.connect(f"file:{state_db_path}?mode=ro", uri=True) as conn:
|
|
105
|
+
conn.row_factory = sqlite3.Row
|
|
106
|
+
tables = {row[0] for row in conn.execute("select name from sqlite_master where type='table'")}
|
|
107
|
+
if "sessions" not in tables:
|
|
108
|
+
return {}
|
|
109
|
+
session_cols = {row[1] for row in conn.execute("pragma table_info(sessions)")}
|
|
110
|
+
if "id" not in session_cols:
|
|
111
|
+
return {}
|
|
112
|
+
message_cols: set[str] = set()
|
|
113
|
+
if "messages" in tables:
|
|
114
|
+
message_cols = {row[1] for row in conn.execute("pragma table_info(messages)")}
|
|
115
|
+
title_expr = _optional_expr("title", session_cols)
|
|
116
|
+
source_expr = _optional_expr("source", session_cols)
|
|
117
|
+
parent_expr = _optional_expr("parent_session_id", session_cols)
|
|
118
|
+
msg_expr = _optional_expr("message_count", session_cols, "0")
|
|
119
|
+
workspace_expr = _optional_expr("workspace", session_cols)
|
|
120
|
+
rows = conn.execute(
|
|
121
|
+
f"""
|
|
122
|
+
SELECT id, {title_expr}, {source_expr}, {parent_expr}, {msg_expr}, {workspace_expr}
|
|
123
|
+
FROM sessions
|
|
124
|
+
"""
|
|
125
|
+
).fetchall()
|
|
126
|
+
message_counts: dict[str, int] = {}
|
|
127
|
+
if {"session_id"}.issubset(message_cols):
|
|
128
|
+
for row in conn.execute("SELECT session_id, COUNT(*) AS count FROM messages GROUP BY session_id"):
|
|
129
|
+
message_counts[str(row["session_id"])] = _safe_int(row["count"], 0)
|
|
130
|
+
records: dict[str, dict] = {}
|
|
131
|
+
for row in rows:
|
|
132
|
+
sid = str(row["id"] or "").strip()
|
|
133
|
+
if not sid:
|
|
134
|
+
continue
|
|
135
|
+
count = message_counts.get(sid, _safe_int(row["message_count"], 0))
|
|
136
|
+
records[sid] = {
|
|
137
|
+
"session_id": sid,
|
|
138
|
+
"title": row["title"],
|
|
139
|
+
"message_count": count,
|
|
140
|
+
"source": row["source"],
|
|
141
|
+
"source_tag": row["source"],
|
|
142
|
+
"session_source": row["source"],
|
|
143
|
+
"parent_session_id": row["parent_session_id"],
|
|
144
|
+
"workspace": row["workspace"],
|
|
145
|
+
"_source_name": "state_db",
|
|
146
|
+
}
|
|
147
|
+
return records
|
|
148
|
+
except Exception:
|
|
149
|
+
return {}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _normalize_api_sessions(api_sessions: Iterable[dict] | None) -> dict[str, dict]:
|
|
153
|
+
records: dict[str, dict] = {}
|
|
154
|
+
if api_sessions is None:
|
|
155
|
+
try:
|
|
156
|
+
from api.models import all_sessions
|
|
157
|
+
|
|
158
|
+
api_sessions = all_sessions()
|
|
159
|
+
except Exception:
|
|
160
|
+
api_sessions = []
|
|
161
|
+
for entry in api_sessions or []:
|
|
162
|
+
if not isinstance(entry, dict):
|
|
163
|
+
continue
|
|
164
|
+
record = _record_from_mapping(entry, "api")
|
|
165
|
+
if record:
|
|
166
|
+
records[record["session_id"]] = record
|
|
167
|
+
return records
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _merged_field(sid: str, stores: list[dict[str, dict]], field: str):
|
|
171
|
+
for store in stores:
|
|
172
|
+
value = store.get(sid, {}).get(field)
|
|
173
|
+
if value not in (None, ""):
|
|
174
|
+
return value
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _max_message_count(sid: str, stores: list[dict[str, dict]]) -> int:
|
|
179
|
+
return max((_safe_int(store.get(sid, {}).get("message_count"), 0) for store in stores), default=0)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _lineage_root(sid: str, parent_by_id: dict[str, str | None]) -> str:
|
|
183
|
+
seen: set[str] = set()
|
|
184
|
+
current = sid
|
|
185
|
+
while current and current not in seen:
|
|
186
|
+
seen.add(current)
|
|
187
|
+
parent = parent_by_id.get(current)
|
|
188
|
+
if not parent:
|
|
189
|
+
return current
|
|
190
|
+
current = parent
|
|
191
|
+
return sid
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _webui_origin(*records: dict) -> bool:
|
|
195
|
+
values: list[str] = []
|
|
196
|
+
for record in records:
|
|
197
|
+
for key in ("source", "source_tag", "session_source"):
|
|
198
|
+
value = record.get(key)
|
|
199
|
+
if value is not None:
|
|
200
|
+
values.append(str(value).strip().lower())
|
|
201
|
+
return "webui" in values
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _computed_is_cli_session(row: dict) -> bool:
|
|
205
|
+
sources = {
|
|
206
|
+
str(row.get(key) or "").strip().lower()
|
|
207
|
+
for key in ("session_source", "source_tag", "raw_source", "source", "source_label")
|
|
208
|
+
}
|
|
209
|
+
if "webui" in sources:
|
|
210
|
+
return False
|
|
211
|
+
try:
|
|
212
|
+
from api.agent_sessions import is_cli_session_row
|
|
213
|
+
|
|
214
|
+
return is_cli_session_row(row)
|
|
215
|
+
except Exception:
|
|
216
|
+
source = str(row.get("session_source") or row.get("source_tag") or row.get("raw_source") or row.get("source") or "").strip().lower()
|
|
217
|
+
return source == "cli"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _new_item(session_id: str, kind: str, category: str, recommendation: str, **extra) -> dict:
|
|
221
|
+
item = {
|
|
222
|
+
"session_id": session_id,
|
|
223
|
+
"kind": kind,
|
|
224
|
+
"category": category,
|
|
225
|
+
"recommendation": recommendation,
|
|
226
|
+
}
|
|
227
|
+
item.update(extra)
|
|
228
|
+
return item
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def audit_session_discoverability(
|
|
232
|
+
session_dir: Path,
|
|
233
|
+
state_db_path: Path | None = None,
|
|
234
|
+
*,
|
|
235
|
+
api_sessions: Iterable[dict] | None = None,
|
|
236
|
+
) -> dict:
|
|
237
|
+
"""Return a read-only cross-store discoverability report.
|
|
238
|
+
|
|
239
|
+
The audit is intentionally diagnostic only. It reports cases where
|
|
240
|
+
messageful sessions have no visible API/sidebar representative, stale source
|
|
241
|
+
flags can put WebUI sessions into the CLI tab, and index/sidecar/state-db
|
|
242
|
+
drift can make a session harder to resolve.
|
|
243
|
+
"""
|
|
244
|
+
session_dir = Path(session_dir)
|
|
245
|
+
sidecars = _read_sidecars(session_dir)
|
|
246
|
+
index = _read_index(session_dir)
|
|
247
|
+
state = _read_state_db(state_db_path)
|
|
248
|
+
api = _normalize_api_sessions(api_sessions)
|
|
249
|
+
stores = [sidecars, index, state, api]
|
|
250
|
+
all_ids = set().union(*(store.keys() for store in stores))
|
|
251
|
+
|
|
252
|
+
parent_by_id: dict[str, str | None] = {}
|
|
253
|
+
for sid in all_ids:
|
|
254
|
+
parent = _merged_field(sid, stores, "parent_session_id")
|
|
255
|
+
parent_by_id[sid] = str(parent) if parent else None
|
|
256
|
+
api_lineage_ids: set[str] = set()
|
|
257
|
+
api_lineage_representative_by_id: dict[str, str] = {}
|
|
258
|
+
for sid, row in api.items():
|
|
259
|
+
explicit_root = row.get("_lineage_root_id")
|
|
260
|
+
if explicit_root:
|
|
261
|
+
root_id = str(explicit_root)
|
|
262
|
+
api_lineage_ids.add(root_id)
|
|
263
|
+
api_lineage_representative_by_id.setdefault(root_id, sid)
|
|
264
|
+
current = sid
|
|
265
|
+
seen: set[str] = set()
|
|
266
|
+
while current and current not in seen:
|
|
267
|
+
seen.add(current)
|
|
268
|
+
api_lineage_ids.add(current)
|
|
269
|
+
api_lineage_representative_by_id.setdefault(current, sid)
|
|
270
|
+
current = parent_by_id.get(current) or ""
|
|
271
|
+
|
|
272
|
+
items: list[dict] = []
|
|
273
|
+
for sid in sorted(all_ids):
|
|
274
|
+
message_count = _max_message_count(sid, stores)
|
|
275
|
+
present_in = {
|
|
276
|
+
"sidecar": sid in sidecars,
|
|
277
|
+
"index": sid in index,
|
|
278
|
+
"state_db": sid in state,
|
|
279
|
+
"api": sid in api,
|
|
280
|
+
}
|
|
281
|
+
sidecar = sidecars.get(sid, {})
|
|
282
|
+
index_row = index.get(sid, {})
|
|
283
|
+
state_row = state.get(sid, {})
|
|
284
|
+
api_row = api.get(sid, {})
|
|
285
|
+
webui_origin = _webui_origin(sidecar, index_row, state_row, api_row)
|
|
286
|
+
|
|
287
|
+
api_is_cli = api_row.get("is_cli_session") is True
|
|
288
|
+
api_computed_is_cli = _computed_is_cli_session(api_row) if api_row else False
|
|
289
|
+
index_is_cli = index_row.get("is_cli_session") is True
|
|
290
|
+
sidecar_is_cli = sidecar.get("is_cli_session") is True
|
|
291
|
+
lineage_root = _lineage_root(sid, parent_by_id)
|
|
292
|
+
api_representative = api_lineage_representative_by_id.get(sid) or api_lineage_representative_by_id.get(lineage_root)
|
|
293
|
+
api_lineage_extra = {
|
|
294
|
+
"represented_by_api_lineage": bool(api_representative),
|
|
295
|
+
"api_representative_session_id": api_representative,
|
|
296
|
+
}
|
|
297
|
+
if webui_origin and api_is_cli and api_computed_is_cli:
|
|
298
|
+
items.append(_new_item(
|
|
299
|
+
sid,
|
|
300
|
+
"source_misclassified",
|
|
301
|
+
"warning",
|
|
302
|
+
"normalize_api_source_flags",
|
|
303
|
+
message_count=message_count,
|
|
304
|
+
state_source=state_row.get("source"),
|
|
305
|
+
api_is_cli_session=api_row.get("is_cli_session"),
|
|
306
|
+
api_computed_is_cli_session=api_computed_is_cli,
|
|
307
|
+
index_is_cli_session=index_row.get("is_cli_session"),
|
|
308
|
+
sidecar_is_cli_session=sidecar.get("is_cli_session"),
|
|
309
|
+
present_in=present_in,
|
|
310
|
+
**api_lineage_extra,
|
|
311
|
+
))
|
|
312
|
+
elif webui_origin and (api_is_cli or index_is_cli or sidecar_is_cli):
|
|
313
|
+
items.append(_new_item(
|
|
314
|
+
sid,
|
|
315
|
+
"persisted_source_flag_stale",
|
|
316
|
+
"warning",
|
|
317
|
+
"rewrite_persisted_sidebar_source_flags_or_ignore_route_normalizes",
|
|
318
|
+
message_count=message_count,
|
|
319
|
+
state_source=state_row.get("source"),
|
|
320
|
+
api_is_cli_session=api_row.get("is_cli_session"),
|
|
321
|
+
api_computed_is_cli_session=api_computed_is_cli,
|
|
322
|
+
index_is_cli_session=index_row.get("is_cli_session"),
|
|
323
|
+
sidecar_is_cli_session=sidecar.get("is_cli_session"),
|
|
324
|
+
present_in=present_in,
|
|
325
|
+
**api_lineage_extra,
|
|
326
|
+
))
|
|
327
|
+
|
|
328
|
+
if message_count <= 0 or sid in api:
|
|
329
|
+
continue
|
|
330
|
+
|
|
331
|
+
is_hidden_snapshot = bool(sidecar.get("pre_compression_snapshot") or index_row.get("pre_compression_snapshot"))
|
|
332
|
+
if sid in api_lineage_ids or lineage_root in api_lineage_ids:
|
|
333
|
+
continue
|
|
334
|
+
|
|
335
|
+
if is_hidden_snapshot:
|
|
336
|
+
items.append(_new_item(
|
|
337
|
+
sid,
|
|
338
|
+
"lineage_missing_visible_representative",
|
|
339
|
+
"warning",
|
|
340
|
+
"repair_lineage_or_expose_tip",
|
|
341
|
+
message_count=message_count,
|
|
342
|
+
lineage_root=lineage_root,
|
|
343
|
+
present_in=present_in,
|
|
344
|
+
))
|
|
345
|
+
continue
|
|
346
|
+
|
|
347
|
+
if not present_in["sidecar"] and not present_in["index"] and present_in["state_db"]:
|
|
348
|
+
items.append(_new_item(
|
|
349
|
+
sid,
|
|
350
|
+
"state_db_messageful_missing_sidecar",
|
|
351
|
+
"warning",
|
|
352
|
+
"materialize_sidecar_or_archive_state_row",
|
|
353
|
+
message_count=message_count,
|
|
354
|
+
lineage_root=lineage_root,
|
|
355
|
+
present_in=present_in,
|
|
356
|
+
))
|
|
357
|
+
continue
|
|
358
|
+
|
|
359
|
+
items.append(_new_item(
|
|
360
|
+
sid,
|
|
361
|
+
"api_missing_messageful",
|
|
362
|
+
"warning",
|
|
363
|
+
"investigate_sidebar_filters_or_api_merge",
|
|
364
|
+
message_count=message_count,
|
|
365
|
+
lineage_root=lineage_root,
|
|
366
|
+
present_in=present_in,
|
|
367
|
+
))
|
|
368
|
+
|
|
369
|
+
summary = {
|
|
370
|
+
"sessions_seen": len(all_ids),
|
|
371
|
+
"messageful": sum(1 for sid in all_ids if _max_message_count(sid, stores) > 0),
|
|
372
|
+
"visible_api": len(api),
|
|
373
|
+
"warnings": sum(1 for item in items if item.get("category") == "warning"),
|
|
374
|
+
}
|
|
375
|
+
status = "warn" if summary["warnings"] else "ok"
|
|
376
|
+
return {
|
|
377
|
+
"status": status,
|
|
378
|
+
"summary": summary,
|
|
379
|
+
"stores": {
|
|
380
|
+
"sidecar": len(sidecars),
|
|
381
|
+
"index": len(index),
|
|
382
|
+
"state_db": len(state),
|
|
383
|
+
"api": len(api),
|
|
384
|
+
},
|
|
385
|
+
"items": items,
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _atomic_write_json(path: Path, payload) -> None:
|
|
390
|
+
tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}")
|
|
391
|
+
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
392
|
+
os.replace(tmp, path)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _backup_file(path: Path, backup_dir: Path, backed_up: dict[Path, str]) -> str | None:
|
|
396
|
+
if not path.exists():
|
|
397
|
+
return None
|
|
398
|
+
resolved = path.resolve()
|
|
399
|
+
if resolved in backed_up:
|
|
400
|
+
return backed_up[resolved]
|
|
401
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
402
|
+
target = backup_dir / path.name
|
|
403
|
+
if target.exists():
|
|
404
|
+
stem = target.name
|
|
405
|
+
i = 1
|
|
406
|
+
while (backup_dir / f"{stem}.{i}").exists():
|
|
407
|
+
i += 1
|
|
408
|
+
target = backup_dir / f"{stem}.{i}"
|
|
409
|
+
shutil.copy2(path, target)
|
|
410
|
+
backed_up[resolved] = str(target)
|
|
411
|
+
return str(target)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _plan_discoverability_repairs(report: dict) -> list[dict]:
|
|
415
|
+
actions: list[dict] = []
|
|
416
|
+
for item in report.get("items") or []:
|
|
417
|
+
sid = str(item.get("session_id") or "")
|
|
418
|
+
if not sid:
|
|
419
|
+
continue
|
|
420
|
+
if item.get("kind") == "persisted_source_flag_stale":
|
|
421
|
+
if item.get("sidecar_is_cli_session") is True:
|
|
422
|
+
actions.append({"session_id": sid, "action": "clear_sidecar_cli_flag"})
|
|
423
|
+
if item.get("index_is_cli_session") is True:
|
|
424
|
+
actions.append({"session_id": sid, "action": "clear_index_cli_flag"})
|
|
425
|
+
elif item.get("kind") == "state_db_messageful_missing_sidecar":
|
|
426
|
+
actions.append({"session_id": sid, "action": "materialize_sidecar_from_state_db"})
|
|
427
|
+
return actions
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _clear_sidecar_cli_flag(session_dir: Path, sid: str, backup_dir: Path, backed_up: dict[Path, str]) -> dict:
|
|
431
|
+
path = session_dir / f"{sid}.json"
|
|
432
|
+
payload = _read_json(path)
|
|
433
|
+
if not isinstance(payload, dict):
|
|
434
|
+
return {"session_id": sid, "action": "clear_sidecar_cli_flag", "applied": False, "error": "sidecar_unreadable"}
|
|
435
|
+
if not _webui_origin(payload):
|
|
436
|
+
return {"session_id": sid, "action": "clear_sidecar_cli_flag", "applied": False, "skipped": "not_webui_origin"}
|
|
437
|
+
if payload.get("is_cli_session") is not True:
|
|
438
|
+
return {"session_id": sid, "action": "clear_sidecar_cli_flag", "applied": False, "skipped": "already_clear"}
|
|
439
|
+
backup = _backup_file(path, backup_dir, backed_up)
|
|
440
|
+
payload["is_cli_session"] = False
|
|
441
|
+
_atomic_write_json(path, payload)
|
|
442
|
+
return {"session_id": sid, "action": "clear_sidecar_cli_flag", "applied": True, "backup": backup}
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _clear_index_cli_flag(session_dir: Path, sid: str, backup_dir: Path, backed_up: dict[Path, str]) -> dict:
|
|
446
|
+
path = session_dir / "_index.json"
|
|
447
|
+
payload = _read_json(path)
|
|
448
|
+
if not isinstance(payload, list):
|
|
449
|
+
return {"session_id": sid, "action": "clear_index_cli_flag", "applied": False, "error": "index_unreadable"}
|
|
450
|
+
changed = False
|
|
451
|
+
for entry in payload:
|
|
452
|
+
if not isinstance(entry, dict):
|
|
453
|
+
continue
|
|
454
|
+
if str(entry.get("session_id") or "") != sid:
|
|
455
|
+
continue
|
|
456
|
+
if not _webui_origin(entry):
|
|
457
|
+
continue
|
|
458
|
+
if entry.get("is_cli_session") is True:
|
|
459
|
+
entry["is_cli_session"] = False
|
|
460
|
+
changed = True
|
|
461
|
+
if not changed:
|
|
462
|
+
return {"session_id": sid, "action": "clear_index_cli_flag", "applied": False, "skipped": "already_clear_or_missing"}
|
|
463
|
+
backup = _backup_file(path, backup_dir, backed_up)
|
|
464
|
+
_atomic_write_json(path, payload)
|
|
465
|
+
return {"session_id": sid, "action": "clear_index_cli_flag", "applied": True, "backup": backup}
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
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:
|
|
469
|
+
if state_db_path is None:
|
|
470
|
+
return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "error": "state_db_required"}
|
|
471
|
+
target = session_dir / f"{sid}.json"
|
|
472
|
+
if target.exists():
|
|
473
|
+
return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "skipped": "sidecar_exists"}
|
|
474
|
+
try:
|
|
475
|
+
from api.session_recovery import _read_state_db_missing_sidecar_rows, _state_db_row_to_sidecar
|
|
476
|
+
except Exception as exc:
|
|
477
|
+
return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "error": f"recovery_import_failed:{exc}"}
|
|
478
|
+
rows = {str(row.get("id") or ""): row for row in _read_state_db_missing_sidecar_rows(session_dir, state_db_path)}
|
|
479
|
+
row = rows.get(sid)
|
|
480
|
+
if not row:
|
|
481
|
+
return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "skipped": "state_row_not_repairable"}
|
|
482
|
+
payload = _state_db_row_to_sidecar(row)
|
|
483
|
+
_backup_file(state_db_path, backup_dir, backed_up)
|
|
484
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
485
|
+
tmp = target.with_suffix(target.suffix + f".tmp.{os.getpid()}")
|
|
486
|
+
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
487
|
+
try:
|
|
488
|
+
os.link(str(tmp), str(target))
|
|
489
|
+
except FileExistsError:
|
|
490
|
+
return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "skipped": "sidecar_appeared_during_repair"}
|
|
491
|
+
finally:
|
|
492
|
+
try:
|
|
493
|
+
tmp.unlink(missing_ok=True)
|
|
494
|
+
except OSError:
|
|
495
|
+
pass
|
|
496
|
+
index_updated = False
|
|
497
|
+
index_path = session_dir / "_index.json"
|
|
498
|
+
index_payload = _read_json(index_path)
|
|
499
|
+
if not isinstance(index_payload, list):
|
|
500
|
+
index_payload = []
|
|
501
|
+
if not any(isinstance(entry, dict) and str(entry.get("session_id") or "") == sid for entry in index_payload):
|
|
502
|
+
_backup_file(index_path, backup_dir, backed_up)
|
|
503
|
+
index_entry = {key: value for key, value in payload.items() if key not in {"messages", "tool_calls"}}
|
|
504
|
+
index_payload.append(index_entry)
|
|
505
|
+
_atomic_write_json(index_path, index_payload)
|
|
506
|
+
index_updated = True
|
|
507
|
+
return {
|
|
508
|
+
"session_id": sid,
|
|
509
|
+
"action": "materialize_sidecar_from_state_db",
|
|
510
|
+
"applied": True,
|
|
511
|
+
"messages": len(payload.get("messages") or []),
|
|
512
|
+
"index_updated": index_updated,
|
|
513
|
+
"backup": str((backup_dir / state_db_path.name)) if (backup_dir / state_db_path.name).exists() else None,
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def repair_session_discoverability(
|
|
518
|
+
session_dir: Path,
|
|
519
|
+
state_db_path: Path | None = None,
|
|
520
|
+
*,
|
|
521
|
+
api_sessions: Iterable[dict] | None = None,
|
|
522
|
+
dry_run: bool = True,
|
|
523
|
+
backup_dir: Path | None = None,
|
|
524
|
+
) -> dict:
|
|
525
|
+
"""Plan or apply deterministic discoverability repairs.
|
|
526
|
+
|
|
527
|
+
Default mode is read-only. Applying mutations requires ``backup_dir`` and is
|
|
528
|
+
limited to stale persisted WebUI-as-CLI flags plus materializing WebUI
|
|
529
|
+
messageful sidecars from canonical state.db rows.
|
|
530
|
+
"""
|
|
531
|
+
before = audit_session_discoverability(session_dir, state_db_path=state_db_path, api_sessions=api_sessions)
|
|
532
|
+
planned = _plan_discoverability_repairs(before)
|
|
533
|
+
if dry_run:
|
|
534
|
+
return {"ok": True, "dry_run": True, "planned": planned, "applied": [], "before": before, "after": before}
|
|
535
|
+
if backup_dir is None:
|
|
536
|
+
return {"ok": False, "dry_run": False, "error": "backup_dir_required_for_apply", "planned": planned, "applied": [], "before": before}
|
|
537
|
+
|
|
538
|
+
session_dir = Path(session_dir)
|
|
539
|
+
backup_dir = Path(backup_dir)
|
|
540
|
+
backed_up: dict[Path, str] = {}
|
|
541
|
+
applied: list[dict] = []
|
|
542
|
+
for action in planned:
|
|
543
|
+
sid = str(action.get("session_id") or "")
|
|
544
|
+
name = action.get("action")
|
|
545
|
+
try:
|
|
546
|
+
if name == "clear_sidecar_cli_flag":
|
|
547
|
+
applied.append(_clear_sidecar_cli_flag(session_dir, sid, backup_dir, backed_up))
|
|
548
|
+
elif name == "clear_index_cli_flag":
|
|
549
|
+
applied.append(_clear_index_cli_flag(session_dir, sid, backup_dir, backed_up))
|
|
550
|
+
elif name == "materialize_sidecar_from_state_db":
|
|
551
|
+
applied.append(_materialize_sidecar_from_state_db(session_dir, state_db_path, sid, backup_dir, backed_up))
|
|
552
|
+
except Exception as exc:
|
|
553
|
+
applied.append({"session_id": sid, "action": name, "applied": False, "error": str(exc)})
|
|
554
|
+
after = audit_session_discoverability(session_dir, state_db_path=state_db_path, api_sessions=api_sessions)
|
|
555
|
+
errors = [item for item in applied if item.get("error")]
|
|
556
|
+
return {
|
|
557
|
+
"ok": not errors,
|
|
558
|
+
"dry_run": False,
|
|
559
|
+
"planned": planned,
|
|
560
|
+
"applied": applied,
|
|
561
|
+
"backups": sorted(set(backed_up.values())),
|
|
562
|
+
"before": before,
|
|
563
|
+
"after": after,
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def render_discoverability_markdown(report: dict) -> str:
|
|
568
|
+
lines = [
|
|
569
|
+
"# WebUI Session Discoverability Audit",
|
|
570
|
+
"",
|
|
571
|
+
f"Status: `{report.get('status')}`",
|
|
572
|
+
"",
|
|
573
|
+
"## Summary",
|
|
574
|
+
"",
|
|
575
|
+
]
|
|
576
|
+
for key, value in (report.get("summary") or {}).items():
|
|
577
|
+
lines.append(f"- `{key}`: {value}")
|
|
578
|
+
lines.extend(["", "## Stores", ""])
|
|
579
|
+
for key, value in (report.get("stores") or {}).items():
|
|
580
|
+
lines.append(f"- `{key}`: {value}")
|
|
581
|
+
lines.extend(["", "## Findings", ""])
|
|
582
|
+
items = report.get("items") or []
|
|
583
|
+
if items:
|
|
584
|
+
lines.extend(["### By kind", ""])
|
|
585
|
+
for kind, count in sorted(Counter(str(item.get("kind")) for item in items).items()):
|
|
586
|
+
lines.append(f"- `{kind}`: {count}")
|
|
587
|
+
lines.extend(["", "### Details", ""])
|
|
588
|
+
if not items:
|
|
589
|
+
lines.append("No discoverability findings.")
|
|
590
|
+
else:
|
|
591
|
+
for item in items:
|
|
592
|
+
lines.append(
|
|
593
|
+
f"- `{item.get('kind')}` `{item.get('session_id')}` "
|
|
594
|
+
f"messages={item.get('message_count', 'n/a')} recommendation=`{item.get('recommendation')}`"
|
|
595
|
+
)
|
|
596
|
+
present = item.get("present_in")
|
|
597
|
+
if isinstance(present, dict):
|
|
598
|
+
lines.append(
|
|
599
|
+
" - present_in: " + ", ".join(f"{k}={v}" for k, v in sorted(present.items()))
|
|
600
|
+
)
|
|
601
|
+
if item.get("represented_by_api_lineage"):
|
|
602
|
+
lines.append(
|
|
603
|
+
f" - represented_by_api_lineage: true via `{item.get('api_representative_session_id')}`"
|
|
604
|
+
)
|
|
605
|
+
lines.append("")
|
|
606
|
+
return "\n".join(lines)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _main() -> int:
|
|
610
|
+
parser = argparse.ArgumentParser(description="Read-only Hermes WebUI session discoverability audit")
|
|
611
|
+
parser.add_argument("--session-dir", type=Path, required=True)
|
|
612
|
+
parser.add_argument("--state-db", type=Path, default=None)
|
|
613
|
+
parser.add_argument("--format", choices=("json", "markdown"), default="json")
|
|
614
|
+
parser.add_argument("--repair-safe", action="store_true", help="Plan/apply deterministic discoverability repairs")
|
|
615
|
+
parser.add_argument("--apply", action="store_true", help="Apply --repair-safe changes; default is dry-run")
|
|
616
|
+
parser.add_argument("--backup-dir", type=Path, default=None, help="Required with --repair-safe --apply")
|
|
617
|
+
parser.add_argument("--out", type=Path, default=None)
|
|
618
|
+
args = parser.parse_args()
|
|
619
|
+
|
|
620
|
+
if args.repair_safe:
|
|
621
|
+
report = repair_session_discoverability(
|
|
622
|
+
args.session_dir,
|
|
623
|
+
state_db_path=args.state_db,
|
|
624
|
+
dry_run=not args.apply,
|
|
625
|
+
backup_dir=args.backup_dir,
|
|
626
|
+
)
|
|
627
|
+
text = json.dumps(report, sort_keys=True)
|
|
628
|
+
else:
|
|
629
|
+
report = audit_session_discoverability(args.session_dir, state_db_path=args.state_db)
|
|
630
|
+
text = render_discoverability_markdown(report) if args.format == "markdown" else json.dumps(report, sort_keys=True)
|
|
631
|
+
if args.out:
|
|
632
|
+
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
633
|
+
args.out.write_text(text, encoding="utf-8")
|
|
634
|
+
else:
|
|
635
|
+
print(text)
|
|
636
|
+
return 0
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
if __name__ == "__main__":
|
|
640
|
+
raise SystemExit(_main())
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Lightweight in-process invalidation events for session sidebar state."""
|
|
2
|
+
|
|
3
|
+
import queue
|
|
4
|
+
import threading
|
|
5
|
+
|
|
6
|
+
_SESSION_EVENTS_LOCK = threading.Lock()
|
|
7
|
+
_SESSION_EVENTS_SUBSCRIBERS: set[queue.Queue] = set()
|
|
8
|
+
_SESSION_EVENTS_VERSION = 0
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def publish_session_list_changed(reason: str = "session_changed") -> None:
|
|
12
|
+
"""Notify connected browsers that the session sidebar may be stale."""
|
|
13
|
+
global _SESSION_EVENTS_VERSION
|
|
14
|
+
with _SESSION_EVENTS_LOCK:
|
|
15
|
+
_SESSION_EVENTS_VERSION += 1
|
|
16
|
+
payload = {
|
|
17
|
+
"type": "sessions_changed",
|
|
18
|
+
"version": _SESSION_EVENTS_VERSION,
|
|
19
|
+
"reason": reason,
|
|
20
|
+
}
|
|
21
|
+
subscribers = list(_SESSION_EVENTS_SUBSCRIBERS)
|
|
22
|
+
for q in subscribers:
|
|
23
|
+
try:
|
|
24
|
+
q.put_nowait(payload)
|
|
25
|
+
except queue.Full:
|
|
26
|
+
try:
|
|
27
|
+
q.get_nowait()
|
|
28
|
+
except queue.Empty:
|
|
29
|
+
pass
|
|
30
|
+
try:
|
|
31
|
+
q.put_nowait(payload)
|
|
32
|
+
except queue.Full:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def subscribe_session_events() -> queue.Queue:
|
|
37
|
+
q: queue.Queue = queue.Queue(maxsize=1)
|
|
38
|
+
with _SESSION_EVENTS_LOCK:
|
|
39
|
+
_SESSION_EVENTS_SUBSCRIBERS.add(q)
|
|
40
|
+
return q
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def unsubscribe_session_events(q: queue.Queue) -> None:
|
|
44
|
+
with _SESSION_EVENTS_LOCK:
|
|
45
|
+
_SESSION_EVENTS_SUBSCRIBERS.discard(q)
|