@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.
Files changed (233) hide show
  1. package/README.md +213 -0
  2. package/bin/hermes-webui.mjs +588 -0
  3. package/package.json +25 -0
  4. package/scripts/sync-vendor.mjs +74 -0
  5. package/templates/launchd/com.bitseek.hermes-webui.plist +21 -0
  6. package/templates/systemd/hermes-webui.service +13 -0
  7. package/templates/windows/hermes-webui-task.ps1 +3 -0
  8. package/vendor/agent-frontend-shell/.bitseek-source.json +6 -0
  9. package/vendor/agent-frontend-shell/.dockerignore +7 -0
  10. package/vendor/agent-frontend-shell/.env.docker.example +89 -0
  11. package/vendor/agent-frontend-shell/.env.example +34 -0
  12. package/vendor/agent-frontend-shell/.github/FUNDING.yml +3 -0
  13. package/vendor/agent-frontend-shell/.github/workflows/browser-smoke.yml +42 -0
  14. package/vendor/agent-frontend-shell/.github/workflows/docker-smoke.yml +233 -0
  15. package/vendor/agent-frontend-shell/.github/workflows/native-windows-startup.yml +132 -0
  16. package/vendor/agent-frontend-shell/.github/workflows/release.yml +57 -0
  17. package/vendor/agent-frontend-shell/.github/workflows/tests.yml +88 -0
  18. package/vendor/agent-frontend-shell/.vscode/launch.json +59 -0
  19. package/vendor/agent-frontend-shell/.vscode/settings.json +13 -0
  20. package/vendor/agent-frontend-shell/AGENTS.md +80 -0
  21. package/vendor/agent-frontend-shell/ARCHITECTURE.md +1658 -0
  22. package/vendor/agent-frontend-shell/BUGS.md +52 -0
  23. package/vendor/agent-frontend-shell/CHANGELOG.md +7295 -0
  24. package/vendor/agent-frontend-shell/CONTRIBUTING.md +205 -0
  25. package/vendor/agent-frontend-shell/CONTRIBUTORS.md +107 -0
  26. package/vendor/agent-frontend-shell/DESIGN.md +173 -0
  27. package/vendor/agent-frontend-shell/Dockerfile +91 -0
  28. package/vendor/agent-frontend-shell/LICENSE +21 -0
  29. package/vendor/agent-frontend-shell/README-CUSTOM.md +76 -0
  30. package/vendor/agent-frontend-shell/README.md +705 -0
  31. package/vendor/agent-frontend-shell/ROADMAP.md +351 -0
  32. package/vendor/agent-frontend-shell/SPRINTS.md +147 -0
  33. package/vendor/agent-frontend-shell/TESTING.md +1932 -0
  34. package/vendor/agent-frontend-shell/THEMES.md +170 -0
  35. package/vendor/agent-frontend-shell/api/__init__.py +1 -0
  36. package/vendor/agent-frontend-shell/api/agent_health.py +392 -0
  37. package/vendor/agent-frontend-shell/api/agent_sessions.py +782 -0
  38. package/vendor/agent-frontend-shell/api/auth.py +592 -0
  39. package/vendor/agent-frontend-shell/api/background.py +87 -0
  40. package/vendor/agent-frontend-shell/api/clarify.py +238 -0
  41. package/vendor/agent-frontend-shell/api/commands.py +124 -0
  42. package/vendor/agent-frontend-shell/api/compression_anchor.py +134 -0
  43. package/vendor/agent-frontend-shell/api/config.py +5178 -0
  44. package/vendor/agent-frontend-shell/api/dashboard_probe.py +255 -0
  45. package/vendor/agent-frontend-shell/api/extensions.py +253 -0
  46. package/vendor/agent-frontend-shell/api/gateway_chat.py +435 -0
  47. package/vendor/agent-frontend-shell/api/gateway_watcher.py +230 -0
  48. package/vendor/agent-frontend-shell/api/goals.py +608 -0
  49. package/vendor/agent-frontend-shell/api/helpers.py +474 -0
  50. package/vendor/agent-frontend-shell/api/kanban_bridge.py +1255 -0
  51. package/vendor/agent-frontend-shell/api/metering.py +194 -0
  52. package/vendor/agent-frontend-shell/api/models.py +4210 -0
  53. package/vendor/agent-frontend-shell/api/oauth.py +770 -0
  54. package/vendor/agent-frontend-shell/api/onboarding.py +1046 -0
  55. package/vendor/agent-frontend-shell/api/passkeys.py +365 -0
  56. package/vendor/agent-frontend-shell/api/profiles.py +1499 -0
  57. package/vendor/agent-frontend-shell/api/providers.py +2175 -0
  58. package/vendor/agent-frontend-shell/api/request_diagnostics.py +160 -0
  59. package/vendor/agent-frontend-shell/api/rollback.py +320 -0
  60. package/vendor/agent-frontend-shell/api/routes.py +13990 -0
  61. package/vendor/agent-frontend-shell/api/run_journal.py +284 -0
  62. package/vendor/agent-frontend-shell/api/runner_client.py +156 -0
  63. package/vendor/agent-frontend-shell/api/runtime_adapter.py +431 -0
  64. package/vendor/agent-frontend-shell/api/session_discoverability.py +640 -0
  65. package/vendor/agent-frontend-shell/api/session_events.py +45 -0
  66. package/vendor/agent-frontend-shell/api/session_lifecycle.py +208 -0
  67. package/vendor/agent-frontend-shell/api/session_ops.py +207 -0
  68. package/vendor/agent-frontend-shell/api/session_recovery.py +655 -0
  69. package/vendor/agent-frontend-shell/api/skill_usage.py +32 -0
  70. package/vendor/agent-frontend-shell/api/startup.py +128 -0
  71. package/vendor/agent-frontend-shell/api/state_sync.py +187 -0
  72. package/vendor/agent-frontend-shell/api/streaming.py +7048 -0
  73. package/vendor/agent-frontend-shell/api/system_health.py +167 -0
  74. package/vendor/agent-frontend-shell/api/terminal.py +410 -0
  75. package/vendor/agent-frontend-shell/api/turn_journal.py +214 -0
  76. package/vendor/agent-frontend-shell/api/updates.py +1261 -0
  77. package/vendor/agent-frontend-shell/api/upload.py +322 -0
  78. package/vendor/agent-frontend-shell/api/usage.py +26 -0
  79. package/vendor/agent-frontend-shell/api/workspace.py +867 -0
  80. package/vendor/agent-frontend-shell/api/workspace_git.py +1261 -0
  81. package/vendor/agent-frontend-shell/api/worktrees.py +357 -0
  82. package/vendor/agent-frontend-shell/bootstrap.py +492 -0
  83. package/vendor/agent-frontend-shell/ctl.sh +427 -0
  84. package/vendor/agent-frontend-shell/docker-compose.custom.yml +26 -0
  85. package/vendor/agent-frontend-shell/docker-compose.three-container.yml +168 -0
  86. package/vendor/agent-frontend-shell/docker-compose.two-container.yml +147 -0
  87. package/vendor/agent-frontend-shell/docker-compose.yml +57 -0
  88. package/vendor/agent-frontend-shell/docker_init.bash +459 -0
  89. package/vendor/agent-frontend-shell/docs/CONTRACTS.md +207 -0
  90. package/vendor/agent-frontend-shell/docs/EXTENSIONS.md +212 -0
  91. package/vendor/agent-frontend-shell/docs/ISSUES.md +23 -0
  92. package/vendor/agent-frontend-shell/docs/UIUX-GUIDE.md +196 -0
  93. package/vendor/agent-frontend-shell/docs/advanced-chat-setup.md +83 -0
  94. package/vendor/agent-frontend-shell/docs/docker.md +337 -0
  95. package/vendor/agent-frontend-shell/docs/onboarding-agent-checklist.md +207 -0
  96. package/vendor/agent-frontend-shell/docs/onboarding.md +202 -0
  97. package/vendor/agent-frontend-shell/docs/remote-access.md +75 -0
  98. package/vendor/agent-frontend-shell/docs/rfcs/README.md +53 -0
  99. package/vendor/agent-frontend-shell/docs/rfcs/agent-source-boundary.md +70 -0
  100. package/vendor/agent-frontend-shell/docs/rfcs/canonical-session-resolution.md +124 -0
  101. package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +1079 -0
  102. package/vendor/agent-frontend-shell/docs/rfcs/turn-journal.md +195 -0
  103. package/vendor/agent-frontend-shell/docs/rfcs/webui-run-state-consistency-contract.md +157 -0
  104. package/vendor/agent-frontend-shell/docs/supervisor.md +280 -0
  105. package/vendor/agent-frontend-shell/docs/troubleshooting.md +132 -0
  106. package/vendor/agent-frontend-shell/docs/ui-ux/index.html +863 -0
  107. package/vendor/agent-frontend-shell/docs/ui-ux/two-stage-proposal.html +768 -0
  108. package/vendor/agent-frontend-shell/docs/why-hermes.md +489 -0
  109. package/vendor/agent-frontend-shell/docs/workspace-git.md +92 -0
  110. package/vendor/agent-frontend-shell/docs/wsl-autostart.md +126 -0
  111. package/vendor/agent-frontend-shell/eslint.runtime-guard.config.mjs +35 -0
  112. package/vendor/agent-frontend-shell/extensions/bitseek-design-system.md +330 -0
  113. package/vendor/agent-frontend-shell/extensions/branding/assets/apple-touch-icon.png +0 -0
  114. package/vendor/agent-frontend-shell/extensions/branding/assets/empty-logo.svg +739 -0
  115. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-192.png +0 -0
  116. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-32.png +0 -0
  117. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.png +0 -0
  118. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.svg +745 -0
  119. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.ico +0 -0
  120. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.svg +745 -0
  121. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v2.svg +751 -0
  122. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v3.svg +739 -0
  123. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon.svg +745 -0
  124. package/vendor/agent-frontend-shell/extensions/branding/branding.js +112 -0
  125. package/vendor/agent-frontend-shell/extensions/branding/config.json +14 -0
  126. package/vendor/agent-frontend-shell/extensions/branding/manifest.json +53 -0
  127. package/vendor/agent-frontend-shell/extensions/index.js +67 -0
  128. package/vendor/agent-frontend-shell/extensions/loader/hermes-loader.js +77 -0
  129. package/vendor/agent-frontend-shell/extensions/manifest.json +16 -0
  130. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.css +333 -0
  131. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +487 -0
  132. package/vendor/agent-frontend-shell/extensions/pages/manifest.json +6 -0
  133. package/vendor/agent-frontend-shell/extensions/pages/registry.css +56 -0
  134. package/vendor/agent-frontend-shell/extensions/pages/registry.js +302 -0
  135. package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.css +93 -0
  136. package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.js +98 -0
  137. package/vendor/agent-frontend-shell/install.sh +63 -0
  138. package/vendor/agent-frontend-shell/mcp_server.py +567 -0
  139. package/vendor/agent-frontend-shell/package.json +12 -0
  140. package/vendor/agent-frontend-shell/pyproject.toml +56 -0
  141. package/vendor/agent-frontend-shell/pytest.ini +3 -0
  142. package/vendor/agent-frontend-shell/requirements.txt +5 -0
  143. package/vendor/agent-frontend-shell/server.py +624 -0
  144. package/vendor/agent-frontend-shell/start.ps1 +210 -0
  145. package/vendor/agent-frontend-shell/start.sh +65 -0
  146. package/vendor/agent-frontend-shell/static/apple-touch-icon.png +0 -0
  147. package/vendor/agent-frontend-shell/static/boot.js +1990 -0
  148. package/vendor/agent-frontend-shell/static/commands.js +1402 -0
  149. package/vendor/agent-frontend-shell/static/favicon-192.png +0 -0
  150. package/vendor/agent-frontend-shell/static/favicon-32.png +0 -0
  151. package/vendor/agent-frontend-shell/static/favicon-512.png +0 -0
  152. package/vendor/agent-frontend-shell/static/favicon-512.svg +18 -0
  153. package/vendor/agent-frontend-shell/static/favicon.ico +0 -0
  154. package/vendor/agent-frontend-shell/static/favicon.svg +20 -0
  155. package/vendor/agent-frontend-shell/static/i18n.js +15389 -0
  156. package/vendor/agent-frontend-shell/static/icons.js +92 -0
  157. package/vendor/agent-frontend-shell/static/index.html +1506 -0
  158. package/vendor/agent-frontend-shell/static/login.js +177 -0
  159. package/vendor/agent-frontend-shell/static/manifest.json +53 -0
  160. package/vendor/agent-frontend-shell/static/messages.js +3521 -0
  161. package/vendor/agent-frontend-shell/static/onboarding.js +800 -0
  162. package/vendor/agent-frontend-shell/static/panels.js +7995 -0
  163. package/vendor/agent-frontend-shell/static/pwa-startup.js +83 -0
  164. package/vendor/agent-frontend-shell/static/sessions.js +5165 -0
  165. package/vendor/agent-frontend-shell/static/style.css +4774 -0
  166. package/vendor/agent-frontend-shell/static/sw.js +173 -0
  167. package/vendor/agent-frontend-shell/static/terminal.js +632 -0
  168. package/vendor/agent-frontend-shell/static/ui.js +8997 -0
  169. package/vendor/agent-frontend-shell/static/vendor/js-yaml/4.1.0/js-yaml.min.js +2 -0
  170. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.ttf +0 -0
  171. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff +0 -0
  172. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  173. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  174. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  175. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  176. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  177. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  178. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  179. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  180. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  181. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  182. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  183. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  184. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  185. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.ttf +0 -0
  186. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff +0 -0
  187. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff2 +0 -0
  188. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  189. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  190. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  191. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.ttf +0 -0
  192. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff +0 -0
  193. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff2 +0 -0
  194. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.ttf +0 -0
  195. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff +0 -0
  196. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff2 +0 -0
  197. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  198. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  199. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  200. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.ttf +0 -0
  201. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff +0 -0
  202. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff2 +0 -0
  203. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  204. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  205. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  206. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  207. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  208. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  209. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  210. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  211. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  212. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.ttf +0 -0
  213. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff +0 -0
  214. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff2 +0 -0
  215. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.ttf +0 -0
  216. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff +0 -0
  217. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  218. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.ttf +0 -0
  219. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff +0 -0
  220. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  221. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.ttf +0 -0
  222. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff +0 -0
  223. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  224. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.ttf +0 -0
  225. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff +0 -0
  226. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  227. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  228. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  229. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  230. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.css +1 -0
  231. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.js +1 -0
  232. package/vendor/agent-frontend-shell/static/vendor/smd.min.js +29 -0
  233. package/vendor/agent-frontend-shell/static/workspace.js +680 -0
@@ -0,0 +1,655 @@
1
+ """
2
+ Session recovery from .bak snapshots — last line of defense against
3
+ data-loss bugs like #1558.
4
+
5
+ ``Session.save()`` writes a ``<sid>.json.bak`` snapshot of the previous
6
+ state whenever an incoming save would shrink the messages array. This
7
+ module reads those snapshots back and restores any session whose live
8
+ file has fewer messages than its backup, or whose live file is missing
9
+ while a valid backup remains.
10
+
11
+ Three integration points:
12
+
13
+ 1. ``recover_all_sessions_on_startup()`` — called from server.py at boot,
14
+ scans the session dir, restores any session whose JSON has fewer
15
+ messages than its .bak, and recreates a missing ``<sid>.json`` from an
16
+ orphaned ``<sid>.json.bak`` when the canonical state DB still has that
17
+ session. Idempotent: a clean run is a no-op.
18
+
19
+ 2. ``recover_session(sid)`` — single-session helper backing the
20
+ ``POST /api/session/recover`` endpoint, so users can re-run recovery
21
+ manually if their session was open through a server restart.
22
+
23
+ 3. ``inspect_session_recovery_status(sid)`` — read-only audit returning
24
+ message counts for the live JSON, the .bak, and a recommendation.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import logging
31
+ import os
32
+ import shutil
33
+ import sqlite3
34
+ import threading
35
+ from pathlib import Path
36
+
37
+ from api.turn_journal import (
38
+ derive_turn_journal_states,
39
+ is_terminal_turn_event,
40
+ iter_turn_journal_session_ids,
41
+ read_turn_journal,
42
+ )
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+
47
+ def _msg_count(p: Path) -> int:
48
+ """Return the number of messages in a session JSON file, or -1 on read/parse error.
49
+
50
+ Returns -1 for any non-session-shape file:
51
+ - File can't be read (OSError)
52
+ - Top-level isn't valid JSON or is invalid (JSONDecodeError, ValueError)
53
+ - Top-level isn't a dict (AttributeError on .get) — e.g. ``_index.json``
54
+ which is a top-level list of session metadata, not a session itself.
55
+ The startup recovery scanner globs ``*.json`` and would otherwise
56
+ crash on the first non-dict file it encounters.
57
+ """
58
+ try:
59
+ data = json.loads(p.read_text(encoding='utf-8'))
60
+ except (OSError, json.JSONDecodeError, ValueError):
61
+ return -1
62
+ if not isinstance(data, dict):
63
+ return -1
64
+ msgs = data.get('messages')
65
+ return len(msgs) if isinstance(msgs, list) else -1
66
+
67
+
68
+ def inspect_session_recovery_status(session_path: Path) -> dict:
69
+ """Return a status dict describing whether recovery is recommended.
70
+
71
+ {
72
+ "session_id": "...",
73
+ "live_messages": int, # -1 if live file unreadable
74
+ "bak_messages": int, # -1 if no .bak or unreadable
75
+ "recommend": "restore" | "no_action" | "no_backup",
76
+ }
77
+ """
78
+ bak_path = session_path.with_suffix('.json.bak')
79
+ live_count = _msg_count(session_path)
80
+ if not bak_path.exists():
81
+ return {
82
+ "session_id": session_path.stem,
83
+ "live_messages": live_count,
84
+ "bak_messages": -1,
85
+ "recommend": "no_backup",
86
+ }
87
+ bak_count = _msg_count(bak_path)
88
+ if bak_count > live_count:
89
+ return {
90
+ "session_id": session_path.stem,
91
+ "live_messages": live_count,
92
+ "bak_messages": bak_count,
93
+ "recommend": "restore",
94
+ }
95
+ return {
96
+ "session_id": session_path.stem,
97
+ "live_messages": live_count,
98
+ "bak_messages": bak_count,
99
+ "recommend": "no_action",
100
+ }
101
+
102
+
103
+ def recover_session(session_path: Path) -> dict:
104
+ """Restore session_path from its .bak when the bak has more messages.
105
+
106
+ Returns a status dict identical to ``inspect_session_recovery_status``
107
+ plus a "restored" boolean.
108
+ """
109
+ status = inspect_session_recovery_status(session_path)
110
+ if status["recommend"] != "restore":
111
+ return {**status, "restored": False}
112
+ bak_path = session_path.with_suffix('.json.bak')
113
+ # Stage the recovery via a tmp copy + atomic replace so a crash mid-restore
114
+ # cannot leave a half-written session.json.
115
+ tmp_path = session_path.with_suffix('.json.recover.tmp')
116
+ try:
117
+ shutil.copyfile(bak_path, tmp_path)
118
+ tmp_path.replace(session_path)
119
+ except OSError as exc:
120
+ logger.warning("recover_session: copy failed for %s: %s", session_path, exc)
121
+ try:
122
+ tmp_path.unlink(missing_ok=True)
123
+ except OSError:
124
+ pass
125
+ return {**status, "restored": False, "error": str(exc)}
126
+ logger.warning(
127
+ "recover_session: restored %s from .bak (live=%d → bak=%d messages). "
128
+ "See #1558 for the data-loss class this guards against.",
129
+ session_path.name, status["live_messages"], status["bak_messages"],
130
+ )
131
+ return {**status, "restored": True}
132
+
133
+
134
+ def _state_db_has_session(session_id: str, state_db_path: Path | None) -> bool:
135
+ """Return whether state.db still knows this session.
136
+
137
+ The check is deliberately fail-open: recovery must not be prevented by a
138
+ locked, absent, or older-schema state DB. When a DB is readable and has no
139
+ row, treat the orphan backup as a tombstoned/deleted session and skip it.
140
+ """
141
+ if state_db_path is None or not state_db_path.exists():
142
+ return True
143
+ try:
144
+ with sqlite3.connect(f"file:{state_db_path}?mode=ro", uri=True) as conn:
145
+ cur = conn.execute(
146
+ "select 1 from sqlite_master where type='table' and name='sessions'"
147
+ )
148
+ if cur.fetchone() is None:
149
+ return True
150
+ cur = conn.execute("select 1 from sessions where id = ? limit 1", (session_id,))
151
+ return cur.fetchone() is not None
152
+ except Exception as exc:
153
+ logger.debug("state_db session tombstone check failed for %s: %s", session_id, exc)
154
+ return True
155
+
156
+
157
+ def _orphaned_backup_live_paths(
158
+ session_dir: Path,
159
+ state_db_path: Path | None = None,
160
+ ) -> list[Path]:
161
+ """Return live ``<sid>.json`` paths whose ``<sid>.json.bak`` exists.
162
+
163
+ ``Path.glob('*.json')`` does not see orphan backups because their suffix is
164
+ ``.bak``. Existing startup recovery only handled shrunken live files; this
165
+ helper covers the crash shape where the live sidecar is gone but the rescue
166
+ copy remains.
167
+ """
168
+ paths: list[Path] = []
169
+ for bak_path in sorted(session_dir.glob('*.json.bak')):
170
+ live_path = bak_path.with_suffix('')
171
+ if live_path.name.startswith('_') or live_path.exists():
172
+ continue
173
+ if _msg_count(bak_path) < 0:
174
+ continue
175
+ session_id = live_path.stem
176
+ if not _state_db_has_session(session_id, state_db_path):
177
+ logger.info(
178
+ "recover_all_sessions_on_startup: skipped orphan backup %s; "
179
+ "state.db has no live session row",
180
+ bak_path.name,
181
+ )
182
+ continue
183
+ paths.append(live_path)
184
+ return paths
185
+
186
+
187
+ def _read_state_db_missing_sidecar_rows(
188
+ session_dir: Path,
189
+ state_db_path: Path | None,
190
+ *,
191
+ include_empty: bool = False,
192
+ ) -> list[dict]:
193
+ """Return WebUI-origin state.db rows whose JSON sidecar is missing."""
194
+ if state_db_path is None or not state_db_path.exists():
195
+ return []
196
+ try:
197
+ with sqlite3.connect(f"file:{state_db_path}?mode=ro", uri=True) as conn:
198
+ conn.row_factory = sqlite3.Row
199
+ session_cols = {row[1] for row in conn.execute("PRAGMA table_info(sessions)").fetchall()}
200
+ message_cols = {row[1] for row in conn.execute("PRAGMA table_info(messages)").fetchall()}
201
+ if not {'id', 'source'}.issubset(session_cols):
202
+ return []
203
+ title_expr = _sql_optional_col('title', session_cols)
204
+ model_expr = _sql_optional_col('model', session_cols)
205
+ started_expr = _sql_optional_col('started_at', session_cols, '0')
206
+ parent_expr = _sql_optional_col('parent_session_id', session_cols)
207
+ msg_count_expr = _sql_optional_col('message_count', session_cols, '0')
208
+ workspace_expr = _sql_optional_col('workspace', session_cols)
209
+ worktree_path_expr = _sql_optional_col('worktree_path', session_cols)
210
+ worktree_branch_expr = _sql_optional_col('worktree_branch', session_cols)
211
+ worktree_repo_root_expr = _sql_optional_col('worktree_repo_root', session_cols)
212
+ worktree_created_at_expr = _sql_optional_col('worktree_created_at', session_cols)
213
+ rows = []
214
+ for row in conn.execute(
215
+ f"""
216
+ SELECT id, source, {title_expr}, {model_expr}, {started_expr},
217
+ {parent_expr}, {msg_count_expr}, {workspace_expr},
218
+ {worktree_path_expr}, {worktree_branch_expr},
219
+ {worktree_repo_root_expr}, {worktree_created_at_expr}
220
+ FROM sessions
221
+ WHERE source = 'webui'
222
+ ORDER BY COALESCE(started_at, 0) DESC
223
+ """
224
+ ).fetchall():
225
+ data = dict(row)
226
+ sid = str(data.get('id') or '').strip()
227
+ if not sid or (session_dir / f"{sid}.json").exists():
228
+ continue
229
+ message_rows: list[dict] = []
230
+ if {'session_id', 'role', 'content'}.issubset(message_cols):
231
+ order = "timestamp, id" if 'timestamp' in message_cols and 'id' in message_cols else "rowid"
232
+ ts_expr = 'timestamp' if 'timestamp' in message_cols else 'NULL AS timestamp'
233
+ for msg in conn.execute(
234
+ f"SELECT role, content, {ts_expr} FROM messages WHERE session_id = ? ORDER BY {order}",
235
+ (sid,),
236
+ ).fetchall():
237
+ message = {
238
+ 'role': msg['role'],
239
+ 'content': msg['content'] or '',
240
+ }
241
+ if msg['timestamp'] is not None:
242
+ message['timestamp'] = msg['timestamp']
243
+ message_rows.append(message)
244
+ if not message_rows and not include_empty:
245
+ continue
246
+ data['messages'] = message_rows
247
+ data['_state_db_empty_messages'] = not message_rows
248
+ rows.append(data)
249
+ return rows
250
+ except Exception as exc:
251
+ logger.debug("state_db sidecar reconciliation scan failed for %s: %s", state_db_path, exc)
252
+ return []
253
+
254
+
255
+ def _sql_optional_col(name: str, columns: set[str], fallback: str = "NULL") -> str:
256
+ return name if name in columns else f"{fallback} AS {name}"
257
+
258
+
259
+ def _state_db_row_to_sidecar(row: dict) -> dict:
260
+ try:
261
+ from api.agent_sessions import normalize_agent_session_source
262
+ except Exception:
263
+ normalize_agent_session_source = None
264
+ source = str(row.get('source') or '').strip().lower()
265
+ source_meta = normalize_agent_session_source(source) if normalize_agent_session_source else {
266
+ 'raw_source': source or None,
267
+ 'session_source': source or None,
268
+ 'source_label': source.title() if source else None,
269
+ }
270
+ started_at = row.get('started_at') or 0
271
+ messages = row.get('messages') if isinstance(row.get('messages'), list) else []
272
+ last_ts = messages[-1].get('timestamp') if messages and isinstance(messages[-1], dict) else started_at
273
+ workspace_value = row.get('workspace') or ''
274
+ return {
275
+ 'session_id': row.get('id'),
276
+ 'title': row.get('title') or 'Recovered WebUI Session',
277
+ 'workspace': workspace_value if isinstance(workspace_value, str) else '',
278
+ 'message_count': row.get('message_count') if isinstance(row.get('message_count'), int) else len(messages),
279
+ 'worktree_path': row.get('worktree_path') or None,
280
+ 'worktree_branch': row.get('worktree_branch') or None,
281
+ 'worktree_repo_root': row.get('worktree_repo_root') or None,
282
+ 'worktree_created_at': row.get('worktree_created_at') or None,
283
+ 'model': row.get('model') or 'unknown',
284
+ 'model_provider': None,
285
+ 'created_at': started_at,
286
+ 'updated_at': last_ts or started_at,
287
+ 'pinned': False,
288
+ 'archived': False,
289
+ 'project_id': None,
290
+ 'profile': None,
291
+ 'input_tokens': 0,
292
+ 'output_tokens': 0,
293
+ 'estimated_cost': None,
294
+ 'personality': None,
295
+ 'active_stream_id': None,
296
+ 'pending_user_message': None,
297
+ 'pending_attachments': [],
298
+ 'pending_started_at': None,
299
+ 'compression_anchor_visible_idx': None,
300
+ 'compression_anchor_message_key': None,
301
+ 'compression_anchor_summary': None,
302
+ 'context_length': None,
303
+ 'threshold_tokens': None,
304
+ 'last_prompt_tokens': None,
305
+ 'gateway_routing': None,
306
+ 'gateway_routing_history': [],
307
+ 'llm_title_generated': False,
308
+ 'parent_session_id': row.get('parent_session_id'),
309
+ 'is_cli_session': False,
310
+ 'source_tag': source or None,
311
+ **source_meta,
312
+ 'enabled_toolsets': None,
313
+ 'composer_draft': {},
314
+ 'messages': messages,
315
+ 'tool_calls': [],
316
+ '_recovered_from_state_db': True,
317
+ }
318
+
319
+
320
+ def recover_missing_sidecars_from_state_db(session_dir: Path, state_db_path: Path | None) -> dict:
321
+ """Materialize missing WebUI JSON sidecars from canonical state.db rows."""
322
+ rows = _read_state_db_missing_sidecar_rows(session_dir, state_db_path)
323
+ materialized = 0
324
+ details: list[dict] = []
325
+ session_dir.mkdir(parents=True, exist_ok=True)
326
+ for row in rows:
327
+ sid = str(row.get('id') or '').strip()
328
+ if not sid:
329
+ continue
330
+ target = session_dir / f"{sid}.json"
331
+ if target.exists():
332
+ continue
333
+ payload = _state_db_row_to_sidecar(row)
334
+ # Per-process/per-thread tmp suffix to avoid corruption under
335
+ # concurrent reconciliation calls (matches api/models.py:484
336
+ # Session.save() convention).
337
+ tmp_suffix = f".json.reconcile.tmp.{os.getpid()}.{threading.current_thread().ident}"
338
+ tmp = target.with_suffix(tmp_suffix)
339
+ detail_recorded = False
340
+ try:
341
+ tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding='utf-8')
342
+ except OSError as exc:
343
+ try:
344
+ tmp.unlink(missing_ok=True)
345
+ except OSError:
346
+ pass
347
+ details.append({'session_id': sid, 'materialized': False, 'error': str(exc)})
348
+ continue
349
+ # Atomic create-or-fail: os.link() refuses to overwrite an existing
350
+ # target. Closes the TOCTOU window between the target.exists() check
351
+ # above and the rename — a concurrent Session.save() for the same SID
352
+ # will win and we silently skip rather than overwrite a live sidecar.
353
+ materialized_now = False
354
+ try:
355
+ os.link(str(tmp), str(target))
356
+ materialized_now = True
357
+ except FileExistsError:
358
+ # Live sidecar appeared between the check and the link — keep it.
359
+ pass
360
+ except OSError as exc:
361
+ details.append({'session_id': sid, 'materialized': False, 'error': str(exc)})
362
+ detail_recorded = True
363
+ finally:
364
+ try:
365
+ tmp.unlink(missing_ok=True)
366
+ except OSError:
367
+ pass
368
+ if materialized_now:
369
+ materialized += 1
370
+ details.append({'session_id': sid, 'materialized': True, 'messages': len(payload.get('messages') or [])})
371
+ elif not detail_recorded:
372
+ details.append({'session_id': sid, 'materialized': False, 'skipped': 'sidecar_appeared_during_reconcile'})
373
+ return {'scanned': len(rows), 'materialized': materialized, 'details': details}
374
+
375
+
376
+ def _new_audit_item(
377
+ session_id: str,
378
+ kind: str,
379
+ category: str,
380
+ recommendation: str,
381
+ live_messages: int = -1,
382
+ bak_messages: int = -1,
383
+ **extra,
384
+ ) -> dict:
385
+ item = {
386
+ "session_id": session_id,
387
+ "kind": kind,
388
+ "category": category,
389
+ "recommendation": recommendation,
390
+ "live_messages": live_messages,
391
+ "bak_messages": bak_messages,
392
+ }
393
+ item.update(extra)
394
+ return item
395
+
396
+
397
+ def _read_index_session_ids(index_path: Path) -> set[str]:
398
+ try:
399
+ data = json.loads(index_path.read_text(encoding='utf-8'))
400
+ except (OSError, json.JSONDecodeError, ValueError):
401
+ return set()
402
+ if not isinstance(data, list):
403
+ return set()
404
+ ids: set[str] = set()
405
+ for entry in data:
406
+ if isinstance(entry, dict) and isinstance(entry.get('session_id'), str):
407
+ ids.add(entry['session_id'])
408
+ return ids
409
+
410
+
411
+ def audit_session_recovery(session_dir: Path, state_db_path: Path | None = None) -> dict:
412
+ """Read-only audit of session recovery state.
413
+
414
+ The audit intentionally does not mutate files. It classifies only the safe
415
+ recovery primitives this module knows how to perform: backup restores and
416
+ derived index rebuilds. Call ``recover_all_sessions_on_startup`` separately
417
+ for safe repairs.
418
+ """
419
+ if not session_dir.exists():
420
+ return {
421
+ "status": "ok",
422
+ "summary": {"ok": 0, "repairable": 0, "unsafe_to_repair": 0},
423
+ "items": [],
424
+ }
425
+
426
+ items: list[dict] = []
427
+ live_paths = sorted(p for p in session_dir.glob('*.json') if not p.name.startswith('_'))
428
+ live_ids = {p.stem for p in live_paths}
429
+
430
+ for live_path in live_paths:
431
+ status = inspect_session_recovery_status(live_path)
432
+ if status.get('recommend') == 'restore':
433
+ items.append(_new_audit_item(
434
+ status['session_id'],
435
+ "shrunken_live",
436
+ "repairable",
437
+ "restore_from_bak",
438
+ status.get('live_messages', -1),
439
+ status.get('bak_messages', -1),
440
+ ))
441
+
442
+ for bak_path in sorted(session_dir.glob('*.json.bak')):
443
+ live_path = bak_path.with_suffix('')
444
+ if live_path.exists() or live_path.name.startswith('_'):
445
+ continue
446
+ bak_messages = _msg_count(bak_path)
447
+ session_id = live_path.stem
448
+ if bak_messages < 0:
449
+ items.append(_new_audit_item(
450
+ session_id, "malformed_orphan_backup", "unsafe_to_repair", "manual_review", -1, bak_messages
451
+ ))
452
+ elif _state_db_has_session(session_id, state_db_path):
453
+ items.append(_new_audit_item(
454
+ session_id, "orphan_backup", "repairable", "restore_from_bak", -1, bak_messages
455
+ ))
456
+ else:
457
+ items.append(_new_audit_item(
458
+ session_id,
459
+ "orphan_backup_without_state_row",
460
+ "unsafe_to_repair",
461
+ "manual_review",
462
+ -1,
463
+ bak_messages,
464
+ ))
465
+
466
+ index_path = session_dir / '_index.json'
467
+ if index_path.exists():
468
+ index_ids = _read_index_session_ids(index_path)
469
+ for session_id in sorted(index_ids - live_ids):
470
+ items.append(_new_audit_item(
471
+ session_id, "index_missing_file", "repairable", "rebuild_index"
472
+ ))
473
+ for session_id in sorted(live_ids - index_ids):
474
+ items.append(_new_audit_item(
475
+ session_id, "index_missing_entry", "repairable", "rebuild_index",
476
+ _msg_count(session_dir / f"{session_id}.json"), -1,
477
+ ))
478
+
479
+ for row in _read_state_db_missing_sidecar_rows(session_dir, state_db_path, include_empty=True):
480
+ sid = str(row.get('id') or '')
481
+ if row.get('_state_db_empty_messages'):
482
+ items.append(_new_audit_item(
483
+ sid,
484
+ "state_db_orphan_webui_row",
485
+ "unsafe_to_repair",
486
+ "manual_review",
487
+ -1,
488
+ -1,
489
+ ))
490
+ continue
491
+ items.append(_new_audit_item(
492
+ sid,
493
+ "state_db_missing_sidecar",
494
+ "repairable",
495
+ "materialize_from_state_db",
496
+ -1,
497
+ -1,
498
+ ))
499
+
500
+ for session_id in iter_turn_journal_session_ids(session_dir):
501
+ journal = read_turn_journal(session_id, session_dir=session_dir)
502
+ states, _ = derive_turn_journal_states(journal.get('events') or [])
503
+ live_path = session_dir / f"{session_id}.json"
504
+ live_messages = _msg_count(live_path)
505
+ existing_user_messages: set[str] = set()
506
+ try:
507
+ payload = json.loads(live_path.read_text(encoding='utf-8'))
508
+ if isinstance(payload, dict):
509
+ for message in payload.get('messages') or []:
510
+ if isinstance(message, dict) and message.get('role') == 'user':
511
+ existing_user_messages.add(str(message.get('content') or '').strip())
512
+ except (OSError, json.JSONDecodeError, ValueError):
513
+ pass
514
+ for turn_id, event in sorted(states.items()):
515
+ if is_terminal_turn_event(event):
516
+ continue
517
+ content = str(event.get('content') or '').strip()
518
+ if not content or content in existing_user_messages:
519
+ continue
520
+ items.append(_new_audit_item(
521
+ session_id,
522
+ "turn_journal_pending_turn",
523
+ "repairable",
524
+ "audit_only_pending_turn_journal",
525
+ live_messages,
526
+ -1,
527
+ turn_id=turn_id,
528
+ event=str(event.get('event') or ''),
529
+ ))
530
+
531
+ summary = {"ok": len(live_paths), "repairable": 0, "unsafe_to_repair": 0}
532
+ for item in items:
533
+ category = item.get('category')
534
+ if category in summary:
535
+ summary[category] += 1
536
+ if summary["unsafe_to_repair"]:
537
+ overall = "needs_manual_review"
538
+ elif summary["repairable"]:
539
+ overall = "warn"
540
+ else:
541
+ overall = "ok"
542
+ return {"status": overall, "summary": summary, "items": items}
543
+
544
+
545
+ def repair_safe_session_recovery(session_dir: Path, state_db_path: Path | None = None) -> dict:
546
+ """Run safe, deterministic session recovery repairs.
547
+
548
+ This mutates only repairable classes already handled by startup recovery:
549
+ shrunken live sidecars and orphan backups that are not tombstoned by a
550
+ readable state.db. Unsafe audit findings remain for manual review.
551
+ """
552
+ before = audit_session_recovery(session_dir, state_db_path=state_db_path)
553
+ backup_repair = recover_all_sessions_on_startup(
554
+ session_dir,
555
+ rebuild_index=True,
556
+ state_db_path=state_db_path,
557
+ )
558
+ sidecar_repair = recover_missing_sidecars_from_state_db(session_dir, state_db_path)
559
+ if sidecar_repair.get('materialized'):
560
+ try:
561
+ from api.models import _write_session_index
562
+ _write_session_index(updates=None)
563
+ except Exception as exc:
564
+ logger.warning("repair_safe_session_recovery: index rebuild after state.db reconciliation failed: %s", exc)
565
+ after = audit_session_recovery(session_dir, state_db_path=state_db_path)
566
+ unsafe_remaining = int((after.get("summary") or {}).get("unsafe_to_repair") or 0)
567
+ repairable_remaining = int((after.get("summary") or {}).get("repairable") or 0)
568
+ clean = unsafe_remaining == 0 and repairable_remaining == 0
569
+ return {
570
+ "clean": clean,
571
+ "ok": clean,
572
+ "repaired": int(backup_repair.get("restored") or 0) + int(sidecar_repair.get("materialized") or 0),
573
+ "before": before,
574
+ "backup_repair": backup_repair,
575
+ "sidecar_repair": sidecar_repair,
576
+ "after": after,
577
+ }
578
+
579
+
580
+ def recover_all_sessions_on_startup(
581
+ session_dir: Path,
582
+ rebuild_index: bool = False,
583
+ state_db_path: Path | None = None,
584
+ ) -> dict:
585
+ """Scan session_dir for shrunken/orphaned sessions and restore from .bak.
586
+
587
+ Returns {"scanned": N, "restored": M, "orphaned_backups": K, "details": [...]}.
588
+ """
589
+ if not session_dir.exists():
590
+ return {"scanned": 0, "restored": 0, "orphaned_backups": 0, "details": []}
591
+ scanned = 0
592
+ restored = 0
593
+ details: list[dict] = []
594
+ live_paths = [path for path in sorted(session_dir.glob('*.json')) if not path.name.startswith('_')]
595
+ orphan_paths = _orphaned_backup_live_paths(session_dir, state_db_path=state_db_path)
596
+ for path in [*live_paths, *orphan_paths]:
597
+ # Skip non-session JSON files in the same dir:
598
+ # - ``_index.json`` is a top-level list of session metadata
599
+ # - any future non-session JSON marked with the ``_`` convention is
600
+ # skipped automatically (project convention for system files in
601
+ # directories that otherwise hold user data)
602
+ scanned += 1
603
+ try:
604
+ result = recover_session(path)
605
+ except Exception as exc:
606
+ # Defensive: a malformed session file shouldn't break recovery
607
+ # for the rest. Log and continue.
608
+ logger.warning(
609
+ "recover_all_sessions_on_startup: skipped %s due to %s: %s",
610
+ path.name, type(exc).__name__, exc,
611
+ )
612
+ continue
613
+ if result.get("restored"):
614
+ restored += 1
615
+ details.append(result)
616
+ if restored:
617
+ logger.warning(
618
+ "recover_all_sessions_on_startup: restored %d/%d sessions from .bak. "
619
+ "If you weren't expecting this, check the session list for missing "
620
+ "messages — see #1558.", restored, scanned,
621
+ )
622
+ if rebuild_index:
623
+ try:
624
+ from api.models import SESSION_INDEX_FILE, _write_session_index
625
+ if restored or not SESSION_INDEX_FILE.exists():
626
+ _write_session_index(updates=None)
627
+ except Exception as exc:
628
+ logger.warning("recover_all_sessions_on_startup: index rebuild failed: %s", exc)
629
+ return {
630
+ "scanned": scanned,
631
+ "restored": restored,
632
+ "orphaned_backups": len(orphan_paths),
633
+ "details": details,
634
+ }
635
+
636
+
637
+ def _main() -> int:
638
+ parser = argparse.ArgumentParser(description="Audit Hermes WebUI session recovery state")
639
+ parser.add_argument("--audit", action="store_true", help="run a read-only recovery audit")
640
+ parser.add_argument("--session-dir", type=Path, required=True, help="path to WebUI sessions directory")
641
+ parser.add_argument("--state-db", type=Path, default=None, help="optional Hermes state.db path")
642
+ parser.add_argument("--repair-safe", action="store_true", help="run safe deterministic repairs after auditing")
643
+ args = parser.parse_args()
644
+ if args.repair_safe:
645
+ report = repair_safe_session_recovery(args.session_dir, state_db_path=args.state_db)
646
+ elif args.audit:
647
+ report = audit_session_recovery(args.session_dir, state_db_path=args.state_db)
648
+ else:
649
+ parser.error("choose --audit or --repair-safe")
650
+ print(json.dumps(report, sort_keys=True))
651
+ return 0
652
+
653
+
654
+ if __name__ == "__main__":
655
+ raise SystemExit(_main())
@@ -0,0 +1,32 @@
1
+ # ── Skill usage reader (read-only) ──
2
+ # Note: .usage.json is written by hermes-agent (tools/skill_usage.py).
3
+ # WebUI only reads to display usage stats in Insights page.
4
+
5
+ import json
6
+ import logging
7
+ from pathlib import Path
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ _USAGE_FILE = ".usage.json"
12
+
13
+
14
+ def read_skill_usage(skills_dir: Path) -> dict:
15
+ """Read the current .usage.json.
16
+
17
+ Returns the raw nested dict ``{skill_name: {use_count: N, view_count: N, ...}}``
18
+ or an empty dict when the file does not exist or is corrupt.
19
+ """
20
+ usage_path = skills_dir / _USAGE_FILE
21
+ if not usage_path.exists():
22
+ return {}
23
+ try:
24
+ raw = usage_path.read_text(encoding="utf-8")
25
+ data = json.loads(raw)
26
+ if isinstance(data, dict):
27
+ return data
28
+ logger.debug("Unexpected .usage.json format, resetting: %s", raw[:200])
29
+ return {}
30
+ except (json.JSONDecodeError, OSError) as exc:
31
+ logger.debug("Failed to read .usage.json: %s", exc)
32
+ return {}