talkops-opscloud 0.3.0b1__py3-none-any.whl

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 (309) hide show
  1. opscloud/__init__.py +5 -0
  2. opscloud/__main__.py +6 -0
  3. opscloud/_constants.py +53 -0
  4. opscloud/_debug.py +145 -0
  5. opscloud/_debug_buffer.py +250 -0
  6. opscloud/_textual_patches.py +367 -0
  7. opscloud/_version.py +15 -0
  8. opscloud/agent/__init__.py +27 -0
  9. opscloud/agent/config.py +168 -0
  10. opscloud/agent/factory.py +1103 -0
  11. opscloud/approval_mode.py +44 -0
  12. opscloud/backend/__init__.py +49 -0
  13. opscloud/backend/composite.py +89 -0
  14. opscloud/backend/local.py +183 -0
  15. opscloud/backend/registry.py +79 -0
  16. opscloud/backend/sandbox_config.py +107 -0
  17. opscloud/backend/sandbox_factory.py +432 -0
  18. opscloud/built_in_skills/remember/SKILL.md +124 -0
  19. opscloud/built_in_skills/skill-creator/SKILL.md +348 -0
  20. opscloud/built_in_skills/skill-creator/scripts/init_skill.py +371 -0
  21. opscloud/built_in_skills/skill-creator/scripts/quick_validate.py +165 -0
  22. opscloud/cli/__init__.py +1 -0
  23. opscloud/cli/commands/__init__.py +15 -0
  24. opscloud/cli/commands/auth.py +45 -0
  25. opscloud/cli/commands/doctor.py +100 -0
  26. opscloud/cli/commands/mcp.py +137 -0
  27. opscloud/cli/commands/threads.py +51 -0
  28. opscloud/cli/interactive.py +154 -0
  29. opscloud/cli/main.py +903 -0
  30. opscloud/cli/non_interactive.py +425 -0
  31. opscloud/cli/server_manager.py +58 -0
  32. opscloud/commands/__init__.py +19 -0
  33. opscloud/commands/_base.py +110 -0
  34. opscloud/commands/_guards.py +52 -0
  35. opscloud/commands/_router.py +146 -0
  36. opscloud/commands/_types.py +36 -0
  37. opscloud/commands/core/__init__.py +47 -0
  38. opscloud/commands/core/auth.py +94 -0
  39. opscloud/commands/core/bug.py +46 -0
  40. opscloud/commands/core/clear.py +106 -0
  41. opscloud/commands/core/cloud.py +185 -0
  42. opscloud/commands/core/compact.py +128 -0
  43. opscloud/commands/core/config_cmd.py +120 -0
  44. opscloud/commands/core/context.py +220 -0
  45. opscloud/commands/core/cost.py +44 -0
  46. opscloud/commands/core/doctor.py +535 -0
  47. opscloud/commands/core/effort.py +108 -0
  48. opscloud/commands/core/exit_cmd.py +41 -0
  49. opscloud/commands/core/fast.py +148 -0
  50. opscloud/commands/core/help_cmd.py +94 -0
  51. opscloud/commands/core/mcp.py +134 -0
  52. opscloud/commands/core/model.py +109 -0
  53. opscloud/commands/core/permissions.py +196 -0
  54. opscloud/commands/core/plugins.py +92 -0
  55. opscloud/commands/core/pool.py +108 -0
  56. opscloud/commands/core/resume.py +97 -0
  57. opscloud/commands/core/skills.py +178 -0
  58. opscloud/commands/power/__init__.py +51 -0
  59. opscloud/commands/power/agents.py +98 -0
  60. opscloud/commands/power/btw.py +85 -0
  61. opscloud/commands/power/copy.py +97 -0
  62. opscloud/commands/power/goal.py +548 -0
  63. opscloud/commands/power/loop.py +295 -0
  64. opscloud/commands/power/memory.py +287 -0
  65. opscloud/commands/power/review.py +152 -0
  66. opscloud/commands/power/rubric.py +287 -0
  67. opscloud/commands/power/runtime.py +321 -0
  68. opscloud/commands/power/skill_creator.py +70 -0
  69. opscloud/commands/power/skill_invoke.py +165 -0
  70. opscloud/commands/power/tasks.py +209 -0
  71. opscloud/commands/power/trace.py +117 -0
  72. opscloud/commands/power/ui_toggles.py +135 -0
  73. opscloud/commands/power/version.py +52 -0
  74. opscloud/commands/registry.py +176 -0
  75. opscloud/config/__init__.py +114 -0
  76. opscloud/config/adapters/__init__.py +5 -0
  77. opscloud/config/adapters/postgres.py +776 -0
  78. opscloud/config/adapters/sqlite.py +769 -0
  79. opscloud/config/aws.py +248 -0
  80. opscloud/config/cloud_profiles.py +437 -0
  81. opscloud/config/env_vars.py +81 -0
  82. opscloud/config/langsmith.py +319 -0
  83. opscloud/config/manifest.py +817 -0
  84. opscloud/config/metadata.py +205 -0
  85. opscloud/config/paths.py +604 -0
  86. opscloud/config/plugins.py +130 -0
  87. opscloud/config/settings.py +834 -0
  88. opscloud/config/store.py +453 -0
  89. opscloud/config/store_factory.py +97 -0
  90. opscloud/config/toml_config.py +562 -0
  91. opscloud/editor.py +173 -0
  92. opscloud/exceptions.py +65 -0
  93. opscloud/file_ops.py +217 -0
  94. opscloud/hooks/__init__.py +139 -0
  95. opscloud/hooks/env.py +44 -0
  96. opscloud/hooks/interrupt.py +121 -0
  97. opscloud/hooks/models/__init__.py +109 -0
  98. opscloud/hooks/models/adapters.py +19 -0
  99. opscloud/hooks/models/config.py +79 -0
  100. opscloud/hooks/models/domain.py +434 -0
  101. opscloud/hooks/models/transport.py +37 -0
  102. opscloud/hooks/tools.py +231 -0
  103. opscloud/input.py +48 -0
  104. opscloud/integrations/__init__.py +80 -0
  105. opscloud/integrations/base.py +153 -0
  106. opscloud/integrations/content_filter.py +119 -0
  107. opscloud/integrations/event_bus.py +480 -0
  108. opscloud/integrations/hooks.py +238 -0
  109. opscloud/integrations/identity.py +181 -0
  110. opscloud/integrations/notifications.py +134 -0
  111. opscloud/integrations/stream_bridge.py +397 -0
  112. opscloud/integrations/thread_store.py +81 -0
  113. opscloud/json_types.py +20 -0
  114. opscloud/mcp/__init__.py +57 -0
  115. opscloud/mcp/config.py +203 -0
  116. opscloud/mcp/discovery.py +229 -0
  117. opscloud/mcp/mcp_info.py +109 -0
  118. opscloud/mcp/middleware.py +102 -0
  119. opscloud/mcp/preload.py +505 -0
  120. opscloud/mcp/raw_config.py +149 -0
  121. opscloud/mcp/semantic_profiler.py +305 -0
  122. opscloud/mcp/session_manager.py +727 -0
  123. opscloud/mcp/trust.py +105 -0
  124. opscloud/memory/__init__.py +27 -0
  125. opscloud/memory/branch.py +61 -0
  126. opscloud/memory/guard.py +19 -0
  127. opscloud/memory/onboarding.py +120 -0
  128. opscloud/memory/registry.py +134 -0
  129. opscloud/memory/store.py +223 -0
  130. opscloud/middleware/__init__.py +106 -0
  131. opscloud/middleware/_repository_bounds.py +459 -0
  132. opscloud/middleware/ask_user.py +152 -0
  133. opscloud/middleware/auto_mode.py +60 -0
  134. opscloud/middleware/auto_mode_hitl.py +2434 -0
  135. opscloud/middleware/compaction.py +566 -0
  136. opscloud/middleware/configurable_model.py +233 -0
  137. opscloud/middleware/cost_tracking.py +1021 -0
  138. opscloud/middleware/glm_stall_recovery.py +109 -0
  139. opscloud/middleware/goal_criteria.py +1234 -0
  140. opscloud/middleware/goal_state_notice.py +712 -0
  141. opscloud/middleware/goal_tools.py +407 -0
  142. opscloud/middleware/headless_mcp_guard.py +88 -0
  143. opscloud/middleware/jev_model_router.py +1018 -0
  144. opscloud/middleware/local_context.py +460 -0
  145. opscloud/middleware/mcp_context.py +151 -0
  146. opscloud/middleware/mcp_middleware.py +146 -0
  147. opscloud/middleware/memory_guard.py +393 -0
  148. opscloud/middleware/model_retry.py +205 -0
  149. opscloud/middleware/registry.py +137 -0
  150. opscloud/middleware/reliable_rubric.py +709 -0
  151. opscloud/middleware/resume_state.py +91 -0
  152. opscloud/middleware/server_hooks.py +1189 -0
  153. opscloud/middleware/shell_allow_list.py +90 -0
  154. opscloud/middleware/skills.py +382 -0
  155. opscloud/middleware/subagent_artifacts.py +84 -0
  156. opscloud/middleware/subagent_telemetry.py +261 -0
  157. opscloud/middleware/subagents.py +243 -0
  158. opscloud/middleware/tool_filter.py +384 -0
  159. opscloud/middleware/unified_system_message.py +62 -0
  160. opscloud/model/__init__.py +37 -0
  161. opscloud/model/config.py +2992 -0
  162. opscloud/model/factory.py +477 -0
  163. opscloud/model/pool.py +459 -0
  164. opscloud/model/reasoning.py +282 -0
  165. opscloud/offload.py +173 -0
  166. opscloud/output.py +64 -0
  167. opscloud/plugins/__init__.py +189 -0
  168. opscloud/plugins/_json.py +22 -0
  169. opscloud/plugins/adapters/__init__.py +13 -0
  170. opscloud/plugins/adapters/agents.py +112 -0
  171. opscloud/plugins/adapters/commands.py +190 -0
  172. opscloud/plugins/adapters/mcp.py +363 -0
  173. opscloud/plugins/adapters/skills.py +132 -0
  174. opscloud/plugins/commands_cli.py +541 -0
  175. opscloud/plugins/discovery.py +512 -0
  176. opscloud/plugins/manifest.py +335 -0
  177. opscloud/plugins/marketplace.py +893 -0
  178. opscloud/plugins/models.py +334 -0
  179. opscloud/plugins/project_plugins.py +317 -0
  180. opscloud/plugins/protocol.py +31 -0
  181. opscloud/plugins/store.py +905 -0
  182. opscloud/plugins/substitution.py +122 -0
  183. opscloud/project_utils.py +145 -0
  184. opscloud/prompts/__init__.py +37 -0
  185. opscloud/prompts/system.py +483 -0
  186. opscloud/prompts/templates/system_prompt.md +166 -0
  187. opscloud/prompts/types.py +54 -0
  188. opscloud/rubrics/__init__.py +42 -0
  189. opscloud/rubrics/evaluator.py +404 -0
  190. opscloud/rubrics/evidence_extractor.py +229 -0
  191. opscloud/rubrics/generator.py +239 -0
  192. opscloud/rubrics/jev_compiler.py +175 -0
  193. opscloud/rubrics/jev_grader.py +297 -0
  194. opscloud/rubrics/middleware.py +8 -0
  195. opscloud/schema/__init__.py +107 -0
  196. opscloud/schema/interrupts.py +981 -0
  197. opscloud/security/__init__.py +91 -0
  198. opscloud/security/approval_mode.py +528 -0
  199. opscloud/security/approval_mode_source.py +230 -0
  200. opscloud/security/cli_ast_evaluator.py +552 -0
  201. opscloud/security/jev_classifier.py +651 -0
  202. opscloud/security/shell_safety.py +340 -0
  203. opscloud/security/unicode_security.py +439 -0
  204. opscloud/security/url_validation.py +160 -0
  205. opscloud/server/__init__.py +13 -0
  206. opscloud/server/_server_config.py +158 -0
  207. opscloud/server/server.py +242 -0
  208. opscloud/server/server_graph.py +93 -0
  209. opscloud/skills/__init__.py +91 -0
  210. opscloud/skills/commands.py +1099 -0
  211. opscloud/skills/invocation.py +200 -0
  212. opscloud/skills/load.py +388 -0
  213. opscloud/skills/loader.py +23 -0
  214. opscloud/skills/merge.py +65 -0
  215. opscloud/skills/registry.py +263 -0
  216. opscloud/skills/sources.py +174 -0
  217. opscloud/skills/trust.py +417 -0
  218. opscloud/state/__init__.py +142 -0
  219. opscloud/state/base.py +154 -0
  220. opscloud/state/cloud_context.py +262 -0
  221. opscloud/state/goal_channels.py +124 -0
  222. opscloud/state/goal_state_limits.py +175 -0
  223. opscloud/state/resume_state.py +58 -0
  224. opscloud/state/service_context.py +281 -0
  225. opscloud/state/session.py +957 -0
  226. opscloud/state/state_migration.py +86 -0
  227. opscloud/subagents/__init__.py +30 -0
  228. opscloud/subagents/loader.py +331 -0
  229. opscloud/subagents/subagents_parser.py +117 -0
  230. opscloud/subagents/types.py +25 -0
  231. opscloud/tools/__init__.py +36 -0
  232. opscloud/tools/catalog.py +37 -0
  233. opscloud/tools/display.py +24 -0
  234. opscloud/tools/fetch_url.py +176 -0
  235. opscloud/tools/goal_tools.py +423 -0
  236. opscloud/tools/registry.py +105 -0
  237. opscloud/tools/thread.py +32 -0
  238. opscloud/tools/web_search.py +215 -0
  239. opscloud/ui/__init__.py +119 -0
  240. opscloud/ui/app.py +5400 -0
  241. opscloud/ui/app.tcss +479 -0
  242. opscloud/ui/clipboard.py +147 -0
  243. opscloud/ui/command_registry.py +482 -0
  244. opscloud/ui/message_store.py +152 -0
  245. opscloud/ui/permission_store.py +360 -0
  246. opscloud/ui/preferences.py +113 -0
  247. opscloud/ui/prompt_manager.py +96 -0
  248. opscloud/ui/remote_client.py +505 -0
  249. opscloud/ui/textual_adapter.py +1269 -0
  250. opscloud/ui/theme.py +728 -0
  251. opscloud/ui/ui_help.py +415 -0
  252. opscloud/ui/widgets/__init__.py +383 -0
  253. opscloud/ui/widgets/_ask_user_types.py +81 -0
  254. opscloud/ui/widgets/_copy_spans.py +52 -0
  255. opscloud/ui/widgets/_inline_prompt.py +290 -0
  256. opscloud/ui/widgets/_js_eval_display.py +139 -0
  257. opscloud/ui/widgets/_links.py +175 -0
  258. opscloud/ui/widgets/_tool_stream.py +308 -0
  259. opscloud/ui/widgets/agent_selector.py +297 -0
  260. opscloud/ui/widgets/approval.py +741 -0
  261. opscloud/ui/widgets/ask_user.py +539 -0
  262. opscloud/ui/widgets/auth.py +695 -0
  263. opscloud/ui/widgets/auth_manager.py +214 -0
  264. opscloud/ui/widgets/auto_mode_notice.py +146 -0
  265. opscloud/ui/widgets/autocomplete.py +709 -0
  266. opscloud/ui/widgets/chat_input.py +805 -0
  267. opscloud/ui/widgets/cloud_selector.py +528 -0
  268. opscloud/ui/widgets/config_manager.py +652 -0
  269. opscloud/ui/widgets/debug_console.py +1269 -0
  270. opscloud/ui/widgets/devops_renderers.py +184 -0
  271. opscloud/ui/widgets/diff.py +237 -0
  272. opscloud/ui/widgets/effort_selector.py +166 -0
  273. opscloud/ui/widgets/goal_review.py +612 -0
  274. opscloud/ui/widgets/goal_status.py +57 -0
  275. opscloud/ui/widgets/infra_panel.py +141 -0
  276. opscloud/ui/widgets/install_confirm.py +114 -0
  277. opscloud/ui/widgets/loading.py +170 -0
  278. opscloud/ui/widgets/mcp_viewer.py +1204 -0
  279. opscloud/ui/widgets/messages.py +4461 -0
  280. opscloud/ui/widgets/model_selector.py +863 -0
  281. opscloud/ui/widgets/notification_center.py +108 -0
  282. opscloud/ui/widgets/notification_settings.py +119 -0
  283. opscloud/ui/widgets/operation_card.py +106 -0
  284. opscloud/ui/widgets/permissions_manager.py +667 -0
  285. opscloud/ui/widgets/plugin_manager.py +1212 -0
  286. opscloud/ui/widgets/pool_selector.py +376 -0
  287. opscloud/ui/widgets/preamble.py +53 -0
  288. opscloud/ui/widgets/skills_viewer.py +432 -0
  289. opscloud/ui/widgets/status.py +1037 -0
  290. opscloud/ui/widgets/subagent_panel.py +927 -0
  291. opscloud/ui/widgets/theme_selector.py +232 -0
  292. opscloud/ui/widgets/thread_selector.py +358 -0
  293. opscloud/ui/widgets/toast.py +31 -0
  294. opscloud/ui/widgets/tool_display.py +325 -0
  295. opscloud/ui/widgets/tool_renderers.py +234 -0
  296. opscloud/ui/widgets/tool_widgets.py +278 -0
  297. opscloud/ui/widgets/welcome.py +421 -0
  298. opscloud/ui/widgets/welcome_popup.py +102 -0
  299. opscloud/utils/__init__.py +24 -0
  300. opscloud/utils/cost_estimation.py +169 -0
  301. opscloud/utils/git.py +390 -0
  302. opscloud/utils/logger.py +571 -0
  303. opscloud/utils/session_stats.py +115 -0
  304. opscloud/utils/startup_error.py +37 -0
  305. talkops_opscloud-0.3.0b1.dist-info/METADATA +449 -0
  306. talkops_opscloud-0.3.0b1.dist-info/RECORD +309 -0
  307. talkops_opscloud-0.3.0b1.dist-info/WHEEL +4 -0
  308. talkops_opscloud-0.3.0b1.dist-info/entry_points.txt +2 -0
  309. talkops_opscloud-0.3.0b1.dist-info/licenses/LICENSE +175 -0
@@ -0,0 +1,957 @@
1
+ """Thread and session management using LangGraph SQLite checkpoint persistence.
2
+
3
+ Provides high-performance thread listing, batch checkpoint decoding with
4
+ JsonPlusSerializer, delta-channel-aware message counting, multi-tiered LRU caching,
5
+ and cloud environment metadata extraction.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import json
13
+ import logging
14
+ import sqlite3
15
+ import time
16
+ from collections.abc import AsyncGenerator, AsyncIterator, Iterable, Sequence
17
+ from contextlib import asynccontextmanager
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+ from typing import (
21
+ TYPE_CHECKING,
22
+ Any,
23
+ NamedTuple,
24
+ NotRequired,
25
+ Self,
26
+ TypedDict,
27
+ cast,
28
+ )
29
+
30
+ from opscloud.middleware.goal_state_notice import is_internal_message
31
+ from opscloud.state.cloud_context import CloudEnvironmentContext, format_cloud_badge
32
+ from opscloud.utils.logger import get_logger
33
+
34
+ if TYPE_CHECKING:
35
+ import aiosqlite
36
+ from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
37
+
38
+ logger = get_logger(__name__)
39
+
40
+ _aiosqlite_patched = False
41
+ _jsonplus_serializer: JsonPlusSerializer | None = None
42
+ _message_count_cache: dict[str, tuple[str | None, int]] = {}
43
+ _MAX_MESSAGE_COUNT_CACHE = 4096
44
+ _initial_prompt_cache: dict[str, tuple[str | None, str | None]] = {}
45
+ _MAX_INITIAL_PROMPT_CACHE = 4096
46
+ _cloud_badge_cache: dict[str, tuple[str | None, str | None]] = {}
47
+ _recent_threads_cache: dict[tuple[str | None, int], list[ThreadInfo]] = {}
48
+ _MAX_RECENT_THREADS_CACHE_KEYS = 16
49
+ _DEFAULT_SQLITE_TIMEOUT = 5.0
50
+ _SQLITE_MAX_VARIABLE_NUMBER = 500
51
+
52
+
53
+ def _patch_aiosqlite() -> None:
54
+ """Patch aiosqlite.Connection with is_alive() if missing."""
55
+ global _aiosqlite_patched
56
+ if _aiosqlite_patched:
57
+ return
58
+
59
+ import aiosqlite as _aiosqlite
60
+
61
+ if not hasattr(_aiosqlite.Connection, "is_alive"):
62
+ def _is_alive(self: _aiosqlite.Connection) -> bool:
63
+ return self._running and self._connection is not None
64
+
65
+ _aiosqlite.Connection.is_alive = _is_alive # type: ignore[attr-defined]
66
+
67
+ _aiosqlite_patched = True
68
+
69
+
70
+ async def _drain_aiosqlite_worker(conn: Any) -> None:
71
+ """Join the aiosqlite worker thread after its connection is closed."""
72
+ worker = getattr(conn, "_thread", None)
73
+ if worker is None or not worker.is_alive():
74
+ return
75
+ with contextlib.suppress(RuntimeError):
76
+ await asyncio.to_thread(worker.join, 5.0)
77
+
78
+
79
+ async def _guard_sqlite_handle(conn: Any) -> None:
80
+ """Apply connection safety settings."""
81
+ with contextlib.suppress(Exception):
82
+ await conn.execute("PRAGMA busy_timeout = 5000")
83
+
84
+
85
+ _db_path: Path | None = None
86
+
87
+
88
+ def get_db_path() -> Path:
89
+ """Return the canonical path to the SQLite sessions database."""
90
+ global _db_path
91
+ if _db_path is not None:
92
+ return _db_path
93
+ from opscloud.config.paths import SESSIONS_DB_PATH
94
+
95
+ SESSIONS_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
96
+ _db_path = SESSIONS_DB_PATH
97
+ return _db_path
98
+
99
+
100
+ def set_db_path(path: Path) -> None:
101
+ """Override database path (used by tests)."""
102
+ global _db_path
103
+ _db_path = path
104
+ _message_count_cache.clear()
105
+ _initial_prompt_cache.clear()
106
+ _cloud_badge_cache.clear()
107
+ _recent_threads_cache.clear()
108
+
109
+
110
+ @asynccontextmanager
111
+ async def _connect() -> AsyncGenerator[Any, None]:
112
+ """Provide an asynchronous context manager connected to the sessions database."""
113
+ import aiosqlite as _aiosqlite
114
+
115
+ _patch_aiosqlite()
116
+ conn: _aiosqlite.Connection | None = None
117
+ try:
118
+ async with _aiosqlite.connect(str(get_db_path()), timeout=30.0) as opened:
119
+ conn = opened
120
+ await _guard_sqlite_handle(opened)
121
+ yield opened
122
+ finally:
123
+ if conn is not None:
124
+ await _drain_aiosqlite_worker(conn)
125
+
126
+
127
+ class ThreadInfo(TypedDict):
128
+ """Metadata for an OpsCloud execution thread."""
129
+
130
+ thread_id: str
131
+ agent_name: str | None
132
+ updated_at: str | None
133
+ created_at: NotRequired[str | None]
134
+ git_branch: NotRequired[str | None]
135
+ initial_prompt: NotRequired[str | None]
136
+ message_count: NotRequired[int]
137
+ latest_checkpoint_id: NotRequired[str | None]
138
+ cwd: NotRequired[str | None]
139
+ cloud_provider: NotRequired[str | None]
140
+ cloud_badge: NotRequired[str | None]
141
+
142
+
143
+ class _CheckpointSummary(NamedTuple):
144
+ """Structured operational facts extracted from a checkpoint payload."""
145
+
146
+ message_count: int | None
147
+ initial_prompt: str | None
148
+ cloud_provider: str | None
149
+ cloud_badge: str | None
150
+
151
+
152
+ def generate_thread_id() -> str:
153
+ """Generate a chronological UUID7 string for thread identity."""
154
+ try:
155
+ from uuid_utils import uuid7
156
+ return str(uuid7())
157
+ except ImportError:
158
+ import uuid
159
+ return str(uuid.uuid4())
160
+
161
+
162
+ def format_timestamp(iso_timestamp: str | None) -> str:
163
+ """Format an ISO timestamp to human readable date."""
164
+ if not iso_timestamp:
165
+ return ""
166
+ try:
167
+ dt = datetime.fromisoformat(iso_timestamp).astimezone()
168
+ return dt.strftime("%b %d, %-I:%M%p").lower()
169
+ except Exception:
170
+ return ""
171
+
172
+
173
+ def format_relative_timestamp(iso_timestamp: str | None) -> str:
174
+ """Format an ISO timestamp as a relative time string (e.g. 5m ago)."""
175
+ if not iso_timestamp:
176
+ return ""
177
+ try:
178
+ dt = datetime.fromisoformat(iso_timestamp).astimezone()
179
+ except Exception:
180
+ return ""
181
+
182
+ delta = datetime.now(tz=dt.tzinfo) - dt
183
+ seconds = int(delta.total_seconds())
184
+ if seconds < 60:
185
+ return f"{max(0, seconds)}s ago"
186
+ minutes = seconds // 60
187
+ if minutes < 60:
188
+ return f"{minutes}m ago"
189
+ hours = minutes // 60
190
+ if hours < 24:
191
+ return f"{hours}h ago"
192
+ days = hours // 24
193
+ return f"{days}d ago"
194
+
195
+
196
+ async def _table_exists(conn: Any, table: str) -> bool:
197
+ query = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?"
198
+ async with conn.execute(query, (table,)) as cursor:
199
+ return await cursor.fetchone() is not None
200
+
201
+
202
+ _THREADS_LIST_INDEX = "idx_opscloud_threads_list"
203
+
204
+
205
+ async def _ensure_threads_list_index(conn: Any) -> None:
206
+ try:
207
+ await conn.execute(
208
+ f"CREATE INDEX IF NOT EXISTS {_THREADS_LIST_INDEX} ON checkpoints("
209
+ "thread_id, "
210
+ "json_extract(metadata, '$.updated_at'), "
211
+ "checkpoint_id, "
212
+ "json_extract(metadata, '$.agent_name'), "
213
+ "json_extract(metadata, '$.git_branch'), "
214
+ "json_extract(metadata, '$.cwd'))"
215
+ )
216
+ await conn.commit()
217
+ except Exception:
218
+ pass
219
+
220
+
221
+ def _get_jsonplus_serializer() -> JsonPlusSerializer:
222
+ global _jsonplus_serializer
223
+ if _jsonplus_serializer is not None:
224
+ return _jsonplus_serializer
225
+ from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
226
+
227
+ _jsonplus_serializer = JsonPlusSerializer()
228
+ return _jsonplus_serializer
229
+
230
+
231
+ def _thread_freshness(thread: ThreadInfo) -> str | None:
232
+ return thread.get("latest_checkpoint_id") or thread.get("updated_at")
233
+
234
+
235
+ def _cache_message_count(thread_id: str, freshness: str | None, count: int) -> None:
236
+ if len(_message_count_cache) >= _MAX_MESSAGE_COUNT_CACHE:
237
+ _message_count_cache.clear()
238
+ _message_count_cache[thread_id] = (freshness, count)
239
+
240
+
241
+ def _cache_initial_prompt(thread_id: str, freshness: str | None, prompt: str | None) -> None:
242
+ if len(_initial_prompt_cache) >= _MAX_INITIAL_PROMPT_CACHE:
243
+ _initial_prompt_cache.clear()
244
+ _initial_prompt_cache[thread_id] = (freshness, prompt)
245
+
246
+
247
+ def _cache_cloud_badge(thread_id: str, freshness: str | None, badge: str | None) -> None:
248
+ if len(_cloud_badge_cache) >= _MAX_INITIAL_PROMPT_CACHE:
249
+ _cloud_badge_cache.clear()
250
+ _cloud_badge_cache[thread_id] = (freshness, badge)
251
+
252
+
253
+ def _visible_message_count(messages: list[object]) -> int:
254
+ return sum(not is_internal_message(m) for m in messages)
255
+
256
+
257
+ def _checkpoint_messages(data: object) -> list[object] | None:
258
+ if not isinstance(data, dict):
259
+ return None
260
+ channel_values = data.get("channel_values")
261
+ if not isinstance(channel_values, dict):
262
+ return None
263
+ messages = channel_values.get("messages")
264
+ if not isinstance(messages, list):
265
+ return None
266
+ return list(messages)
267
+
268
+
269
+ def _coerce_prompt_text(content: object) -> str | None:
270
+ if isinstance(content, str):
271
+ stripped = content.strip()
272
+ return stripped if stripped else None
273
+ if isinstance(content, list):
274
+ parts: list[str] = []
275
+ for part in content:
276
+ if isinstance(part, str):
277
+ parts.append(part)
278
+ elif isinstance(part, dict) and part.get("type") == "text":
279
+ text = part.get("text")
280
+ if isinstance(text, str):
281
+ parts.append(text)
282
+ joined = " ".join(parts).strip()
283
+ return joined if joined else None
284
+ return None
285
+
286
+
287
+ def _initial_prompt_from_messages(messages: list[object]) -> str | None:
288
+ for msg in messages:
289
+ if is_internal_message(msg):
290
+ continue
291
+ if getattr(msg, "type", None) == "human":
292
+ prompt = _coerce_prompt_text(getattr(msg, "content", None))
293
+ if prompt:
294
+ return prompt
295
+ elif isinstance(msg, dict):
296
+ role = msg.get("role")
297
+ type_ = msg.get("type")
298
+ if role in {"user", "human"} or type_ == "human":
299
+ prompt = _coerce_prompt_text(msg.get("content"))
300
+ if prompt:
301
+ return prompt
302
+ return None
303
+
304
+
305
+ def _summarize_checkpoint(data: object) -> _CheckpointSummary:
306
+ messages = _checkpoint_messages(data)
307
+ count = _visible_message_count(messages) if messages is not None else None
308
+ prompt = _initial_prompt_from_messages(messages or [])
309
+
310
+ cloud_provider: str | None = None
311
+ cloud_badge: str | None = None
312
+ if isinstance(data, dict):
313
+ cv = data.get("channel_values")
314
+ if isinstance(cv, dict):
315
+ provider_val = cv.get("_active_cloud_provider")
316
+ if isinstance(provider_val, str):
317
+ cloud_provider = provider_val
318
+ ctx_val = cv.get("_cloud_context")
319
+ if isinstance(ctx_val, dict):
320
+ cloud_badge = format_cloud_badge(cast(CloudEnvironmentContext, ctx_val))
321
+ elif cloud_provider:
322
+ cloud_badge = f"[{cloud_provider.upper()}]"
323
+
324
+ return _CheckpointSummary(
325
+ message_count=count,
326
+ initial_prompt=prompt,
327
+ cloud_provider=cloud_provider,
328
+ cloud_badge=cloud_badge,
329
+ )
330
+
331
+
332
+ def _count_messages_from_deltas(deltas: list[Any]) -> int:
333
+ from langchain_core.messages import RemoveMessage
334
+ from langgraph.graph.message import REMOVE_ALL_MESSAGES, add_messages
335
+ from langgraph.types import Overwrite
336
+
337
+ buffer: list[Any] = []
338
+ needs_exact_fold = False
339
+ for delta in deltas:
340
+ if isinstance(delta, Overwrite):
341
+ val = delta.value
342
+ buffer = list(val) if isinstance(val, list) else []
343
+ continue
344
+ items = delta if isinstance(delta, list) else [delta]
345
+ for item in items:
346
+ if isinstance(item, RemoveMessage):
347
+ if item.id == REMOVE_ALL_MESSAGES:
348
+ buffer = []
349
+ else:
350
+ needs_exact_fold = True
351
+ break
352
+ else:
353
+ buffer.append(item)
354
+ if needs_exact_fold:
355
+ break
356
+
357
+ if not needs_exact_fold:
358
+ try:
359
+ reduced = cast(list[Any], add_messages([], buffer))
360
+ return _visible_message_count(reduced)
361
+ except Exception:
362
+ pass
363
+
364
+ # Sequential fold fallback
365
+ reduced_seq: list[Any] = []
366
+ for delta in deltas:
367
+ if isinstance(delta, Overwrite):
368
+ val = delta.value
369
+ reduced_seq = list(val) if isinstance(val, list) else []
370
+ continue
371
+ try:
372
+ reduced_seq = cast(list[Any], add_messages(reduced_seq, delta))
373
+ except Exception:
374
+ pass
375
+ return _visible_message_count(reduced_seq)
376
+
377
+
378
+ def _reduce_message_write_rows(
379
+ rows: list[tuple[str, str | None, bytes | None]],
380
+ serde: JsonPlusSerializer,
381
+ ) -> dict[str, int]:
382
+ deltas_by_thread: dict[str, list[Any]] = {}
383
+ for tid, type_str, value_blob in rows:
384
+ if not type_str or not value_blob:
385
+ continue
386
+ try:
387
+ delta = serde.loads_typed((type_str, value_blob))
388
+ deltas_by_thread.setdefault(tid, []).append(delta)
389
+ except Exception:
390
+ continue
391
+
392
+ counts: dict[str, int] = {}
393
+ for tid, deltas in deltas_by_thread.items():
394
+ try:
395
+ counts[tid] = _count_messages_from_deltas(deltas)
396
+ except Exception:
397
+ pass
398
+ return counts
399
+
400
+
401
+ async def _load_latest_checkpoint_summaries_batch(
402
+ conn: Any,
403
+ thread_ids: list[str],
404
+ serde: JsonPlusSerializer,
405
+ ) -> dict[str, _CheckpointSummary]:
406
+ if not thread_ids:
407
+ return {}
408
+ results: dict[str, _CheckpointSummary] = {}
409
+ loop = asyncio.get_running_loop()
410
+
411
+ for start in range(0, len(thread_ids), _SQLITE_MAX_VARIABLE_NUMBER):
412
+ chunk = thread_ids[start : start + _SQLITE_MAX_VARIABLE_NUMBER]
413
+ placeholders = ",".join("?" * len(chunk))
414
+ query = f"""
415
+ SELECT c.thread_id, c.type, c.checkpoint
416
+ FROM checkpoints AS c
417
+ JOIN (
418
+ SELECT rowid AS rid,
419
+ ROW_NUMBER() OVER (
420
+ PARTITION BY thread_id ORDER BY checkpoint_id DESC
421
+ ) AS rn
422
+ FROM checkpoints
423
+ WHERE thread_id IN ({placeholders})
424
+ ) AS ranked ON c.rowid = ranked.rid
425
+ WHERE ranked.rn = 1
426
+ """
427
+ async with conn.execute(query, chunk) as cursor:
428
+ rows = await cursor.fetchall()
429
+
430
+ for tid, type_str, checkpoint_blob in rows:
431
+ if not type_str or not checkpoint_blob:
432
+ results[tid] = _CheckpointSummary(None, None, None, None)
433
+ continue
434
+ try:
435
+ data = await loop.run_in_executor(
436
+ None, serde.loads_typed, (type_str, checkpoint_blob)
437
+ )
438
+ results[tid] = _summarize_checkpoint(data)
439
+ except Exception:
440
+ results[tid] = _CheckpointSummary(None, None, None, None)
441
+
442
+ return results
443
+
444
+
445
+ async def _load_initial_prompts_from_writes_batch(
446
+ conn: Any,
447
+ thread_ids: list[str],
448
+ serde: JsonPlusSerializer,
449
+ ) -> dict[str, str | None]:
450
+ if not thread_ids:
451
+ return {}
452
+ results: dict[str, str | None] = {}
453
+ loop = asyncio.get_running_loop()
454
+
455
+ for start in range(0, len(thread_ids), _SQLITE_MAX_VARIABLE_NUMBER):
456
+ chunk = thread_ids[start : start + _SQLITE_MAX_VARIABLE_NUMBER]
457
+ placeholders = ",".join("?" * len(chunk))
458
+ query = f"""
459
+ SELECT w.thread_id, w.type, w.value
460
+ FROM writes AS w
461
+ JOIN (
462
+ SELECT rowid AS rid,
463
+ ROW_NUMBER() OVER (
464
+ PARTITION BY thread_id
465
+ ORDER BY checkpoint_id ASC, idx ASC
466
+ ) AS rn
467
+ FROM writes
468
+ WHERE thread_id IN ({placeholders}) AND channel = 'messages'
469
+ ) AS ranked ON w.rowid = ranked.rid
470
+ WHERE ranked.rn = 1
471
+ """
472
+ async with conn.execute(query, chunk) as cursor:
473
+ rows = await cursor.fetchall()
474
+
475
+ for tid, type_str, value_blob in rows:
476
+ if not type_str or not value_blob:
477
+ continue
478
+ try:
479
+ messages = await loop.run_in_executor(
480
+ None, serde.loads_typed, (type_str, value_blob)
481
+ )
482
+ if isinstance(messages, list):
483
+ prompt = _initial_prompt_from_messages(messages)
484
+ if prompt:
485
+ results[tid] = prompt
486
+ except Exception:
487
+ pass
488
+ return results
489
+
490
+
491
+ async def _load_message_counts_from_writes_batch(
492
+ conn: Any,
493
+ thread_ids: list[str],
494
+ serde: JsonPlusSerializer,
495
+ ) -> dict[str, int]:
496
+ if not thread_ids:
497
+ return {}
498
+ loop = asyncio.get_running_loop()
499
+ results: dict[str, int] = {}
500
+
501
+ for start in range(0, len(thread_ids), _SQLITE_MAX_VARIABLE_NUMBER):
502
+ chunk = thread_ids[start : start + _SQLITE_MAX_VARIABLE_NUMBER]
503
+ placeholders = ",".join("?" * len(chunk))
504
+ query = f"""
505
+ SELECT thread_id, type, value
506
+ FROM writes
507
+ WHERE thread_id IN ({placeholders})
508
+ AND checkpoint_ns = ''
509
+ AND channel = 'messages'
510
+ ORDER BY thread_id, checkpoint_id ASC, task_id ASC, idx ASC
511
+ """
512
+ async with conn.execute(query, chunk) as cursor:
513
+ rows = await cursor.fetchall()
514
+
515
+ chunk_counts = await loop.run_in_executor(
516
+ None, _reduce_message_write_rows, list(rows), serde
517
+ )
518
+ results.update(chunk_counts)
519
+
520
+ return results
521
+
522
+
523
+ async def _populate_checkpoint_fields(
524
+ conn: Any,
525
+ threads: list[ThreadInfo],
526
+ *,
527
+ include_message_count: bool = True,
528
+ include_initial_prompt: bool = True,
529
+ ) -> None:
530
+ """Populate message count, initial prompt, and cloud context in threads."""
531
+ serde = _get_jsonplus_serializer()
532
+ uncached: list[ThreadInfo] = []
533
+
534
+ for thread in threads:
535
+ thread_id = thread["thread_id"]
536
+ freshness = _thread_freshness(thread)
537
+ needs_count = False
538
+ needs_prompt = False
539
+
540
+ if include_message_count:
541
+ cached_cnt = _message_count_cache.get(thread_id)
542
+ if cached_cnt is not None and cached_cnt[0] == freshness:
543
+ thread["message_count"] = cached_cnt[1]
544
+ else:
545
+ needs_count = True
546
+
547
+ if include_initial_prompt and "initial_prompt" not in thread:
548
+ cached_p = _initial_prompt_cache.get(thread_id)
549
+ if cached_p is not None and cached_p[0] == freshness:
550
+ thread["initial_prompt"] = cached_p[1]
551
+ else:
552
+ needs_prompt = True
553
+
554
+ cached_badge = _cloud_badge_cache.get(thread_id)
555
+ if cached_badge is not None and cached_badge[0] == freshness:
556
+ thread["cloud_badge"] = cached_badge[1]
557
+
558
+ if needs_count or needs_prompt or "cloud_badge" not in thread:
559
+ uncached.append(thread)
560
+
561
+ if not uncached:
562
+ return
563
+
564
+ uncached_ids = [t["thread_id"] for t in uncached]
565
+ batch_results = await _load_latest_checkpoint_summaries_batch(conn, uncached_ids, serde)
566
+ prompt_results = await _load_initial_prompts_from_writes_batch(conn, uncached_ids, serde)
567
+
568
+ needs_writes_count: list[str] = []
569
+ for thread in uncached:
570
+ tid = thread["thread_id"]
571
+ freshness = _thread_freshness(thread)
572
+ summary = batch_results.get(tid)
573
+
574
+ if summary:
575
+ if summary.cloud_badge:
576
+ thread["cloud_badge"] = summary.cloud_badge
577
+ _cache_cloud_badge(tid, freshness, summary.cloud_badge)
578
+ if summary.cloud_provider:
579
+ thread["cloud_provider"] = summary.cloud_provider
580
+
581
+ if include_message_count and "message_count" not in thread:
582
+ if summary and summary.message_count is not None:
583
+ thread["message_count"] = summary.message_count
584
+ _cache_message_count(tid, freshness, summary.message_count)
585
+ else:
586
+ needs_writes_count.append(tid)
587
+
588
+ if include_initial_prompt and "initial_prompt" not in thread:
589
+ prompt = prompt_results.get(tid) or (summary.initial_prompt if summary else None)
590
+ thread["initial_prompt"] = prompt
591
+ _cache_initial_prompt(tid, freshness, prompt)
592
+
593
+ if needs_writes_count:
594
+ writes_counts = await _load_message_counts_from_writes_batch(conn, needs_writes_count, serde)
595
+ uncached_by_id = {t["thread_id"]: t for t in uncached}
596
+ for tid in needs_writes_count:
597
+ cnt = writes_counts.get(tid, 0)
598
+ if tid in uncached_by_id:
599
+ t = uncached_by_id[tid]
600
+ t["message_count"] = cnt
601
+ _cache_message_count(tid, _thread_freshness(t), cnt)
602
+
603
+
604
+ async def list_threads(
605
+ agent_name: str | None = None,
606
+ limit: int = 20,
607
+ include_message_count: bool = True,
608
+ sort_by: str = "updated",
609
+ branch: str | None = None,
610
+ cwd: str | None = None,
611
+ ) -> list[ThreadInfo]:
612
+ """List execution threads with parsed metadata, message counts, and initial prompts."""
613
+ async with _connect() as conn:
614
+ if not await _table_exists(conn, "checkpoints"):
615
+ return []
616
+
617
+ await _ensure_threads_list_index(conn)
618
+
619
+ order_col = "created_at" if sort_by == "created" else "updated_at"
620
+ where_clauses: list[str] = []
621
+ params_list: list[str | int] = []
622
+
623
+ if agent_name:
624
+ where_clauses.append("json_extract(metadata, '$.agent_name') = ?")
625
+ params_list.append(agent_name)
626
+ if branch:
627
+ where_clauses.append("json_extract(metadata, '$.git_branch') = ?")
628
+ params_list.append(branch)
629
+ if cwd:
630
+ where_clauses.append("json_extract(metadata, '$.cwd') = ?")
631
+ params_list.append(cwd)
632
+
633
+ where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
634
+
635
+ query = f"""
636
+ SELECT thread_id,
637
+ json_extract(metadata, '$.agent_name') as agent_name,
638
+ MAX(json_extract(metadata, '$.updated_at')) as updated_at,
639
+ MAX(checkpoint_id) as latest_checkpoint_id,
640
+ MIN(json_extract(metadata, '$.updated_at')) as created_at,
641
+ MAX(json_extract(metadata, '$.git_branch')) as git_branch,
642
+ MAX(json_extract(metadata, '$.cwd')) as cwd
643
+ FROM checkpoints
644
+ {where_sql}
645
+ GROUP BY thread_id
646
+ ORDER BY {order_col} DESC
647
+ LIMIT ?
648
+ """
649
+ params: tuple = (*params_list, limit)
650
+
651
+ async with conn.execute(query, params) as cursor:
652
+ rows = await cursor.fetchall()
653
+ threads: list[ThreadInfo] = [
654
+ ThreadInfo(
655
+ thread_id=r[0],
656
+ agent_name=r[1],
657
+ updated_at=r[2],
658
+ latest_checkpoint_id=r[3],
659
+ created_at=r[4],
660
+ git_branch=r[5],
661
+ cwd=r[6],
662
+ )
663
+ for r in rows
664
+ ]
665
+
666
+ if include_message_count and threads:
667
+ await _populate_checkpoint_fields(
668
+ conn,
669
+ threads,
670
+ include_message_count=True,
671
+ include_initial_prompt=True,
672
+ )
673
+
674
+ return threads
675
+
676
+
677
+ async def prewarm_thread_message_counts(limit: int = 50) -> None:
678
+ """Pre-warm caches in background for snappy TUI thread switching."""
679
+ try:
680
+ await list_threads(limit=limit, include_message_count=True)
681
+ except Exception as exc:
682
+ logger.debug("Failed prewarming thread message counts: %s", exc)
683
+
684
+
685
+ async def delete_thread(thread_id: str) -> bool:
686
+ """Delete a thread and all associated checkpoints and writes."""
687
+ deleted = False
688
+ async with _connect() as conn:
689
+ if await _table_exists(conn, "checkpoints"):
690
+ cursor = await conn.execute(
691
+ "DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,)
692
+ )
693
+ deleted = cursor.rowcount > 0
694
+ if await _table_exists(conn, "writes"):
695
+ await conn.execute(
696
+ "DELETE FROM writes WHERE thread_id = ?", (thread_id,)
697
+ )
698
+ await conn.commit()
699
+ if deleted:
700
+ _message_count_cache.pop(thread_id, None)
701
+ _initial_prompt_cache.pop(thread_id, None)
702
+ _cloud_badge_cache.pop(thread_id, None)
703
+ return deleted
704
+
705
+
706
+ async def thread_exists(thread_id: str) -> bool:
707
+ """Check if thread exists in checkpoints."""
708
+ async with _connect() as conn:
709
+ if not await _table_exists(conn, "checkpoints"):
710
+ return False
711
+ async with conn.execute(
712
+ "SELECT 1 FROM checkpoints WHERE thread_id = ? LIMIT 1", (thread_id,)
713
+ ) as cursor:
714
+ return await cursor.fetchone() is not None
715
+
716
+
717
+ async def find_similar_threads(thread_id_prefix: str, limit: int = 3) -> list[str]:
718
+ """Find threads starting with or matching prefix."""
719
+ async with _connect() as conn:
720
+ if not await _table_exists(conn, "checkpoints"):
721
+ return []
722
+ query = """
723
+ SELECT DISTINCT thread_id FROM checkpoints
724
+ WHERE thread_id LIKE ?
725
+ ORDER BY checkpoint_id DESC
726
+ LIMIT ?
727
+ """
728
+ async with conn.execute(query, (f"{thread_id_prefix}%", limit)) as cursor:
729
+ rows = await cursor.fetchall()
730
+ return [r[0] for r in rows]
731
+
732
+
733
+ async def get_most_recent(
734
+ agent_name: str | None = None,
735
+ branch: str | None = None,
736
+ cwd: str | None = None,
737
+ ) -> ThreadInfo | None:
738
+ """Return the most recently updated thread."""
739
+ threads = await list_threads(
740
+ agent_name=agent_name,
741
+ limit=1,
742
+ include_message_count=True,
743
+ branch=branch,
744
+ cwd=cwd,
745
+ )
746
+ return threads[0] if threads else None
747
+
748
+
749
+ from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
750
+
751
+
752
+ class OpsCloudCheckpointer(AsyncSqliteSaver):
753
+ """Custom SQLite checkpointer implementing LangGraph API extended capabilities.
754
+
755
+ Adheres to the official LangChain custom checkpointer specification by implementing:
756
+ - `adelete_for_runs`: deletes checkpoints and writes associated with specific run IDs,
757
+ enabling the multitask_strategy='rollback' capability.
758
+ - `acopy_thread`: duplicates thread state, checkpoints, and writes for thread forking.
759
+ - `aprune`: prunes thread checkpoint history ('keep_latest' or 'delete_all') to keep
760
+ storage bounded for long-lived threads.
761
+ """
762
+
763
+ @classmethod
764
+ @asynccontextmanager
765
+ async def from_conn_string(
766
+ cls: type[Self], conn_string: str
767
+ ) -> AsyncIterator[Self]:
768
+ """Create a new OpsCloudCheckpointer instance from a connection string."""
769
+ import aiosqlite
770
+
771
+ async with aiosqlite.connect(conn_string) as conn:
772
+ yield cls(conn)
773
+
774
+ async def adelete_for_runs(self, run_ids: Sequence[str] | Iterable[str]) -> None:
775
+ """Asynchronously delete all checkpoints and writes associated with the given run IDs.
776
+
777
+ Enables the LangGraph API multitask_strategy='rollback' to clean up
778
+ checkpoints and intermediate writes when a run is cancelled.
779
+ """
780
+ run_id_list = [r for r in run_ids if r]
781
+ if not run_id_list:
782
+ return
783
+ await self.setup()
784
+ async with self.lock, self.conn.cursor() as cur:
785
+ for r_id in run_id_list:
786
+ await cur.execute(
787
+ "SELECT thread_id, checkpoint_ns, checkpoint_id FROM checkpoints "
788
+ "WHERE json_extract(CAST(metadata AS TEXT), '$.run_id') = ?",
789
+ (r_id,),
790
+ )
791
+ cps = await cur.fetchall()
792
+ for tid, c_ns, cid in cps:
793
+ await cur.execute(
794
+ "DELETE FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ?",
795
+ (tid, c_ns, cid),
796
+ )
797
+ await cur.execute(
798
+ "DELETE FROM checkpoints WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ?",
799
+ (tid, c_ns, cid),
800
+ )
801
+ await self.conn.commit()
802
+
803
+ async def acopy_thread(self, source_thread_id: str, target_thread_id: str) -> None:
804
+ """Asynchronously copy all checkpoints and writes from one thread to another.
805
+
806
+ Supports LangGraph API POST /threads/<id>/copy with fast SQLite bulk duplication.
807
+ """
808
+ source_id = source_thread_id
809
+ target_id = target_thread_id
810
+ await self.setup()
811
+ async with self.lock, self.conn.cursor() as cur:
812
+ await cur.execute(
813
+ "SELECT checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata "
814
+ "FROM checkpoints WHERE thread_id = ?",
815
+ (source_id,),
816
+ )
817
+ rows = await cur.fetchall()
818
+ for checkpoint_ns, checkpoint_id, parent_checkpoint_id, cp_type, checkpoint_blob, metadata_blob in rows:
819
+ updated_metadata_blob = metadata_blob
820
+ if metadata_blob:
821
+ try:
822
+ meta_dict = json.loads(metadata_blob.decode("utf-8", "ignore"))
823
+ if "thread_id" in meta_dict:
824
+ meta_dict["thread_id"] = target_id
825
+ updated_metadata_blob = json.dumps(meta_dict, ensure_ascii=False).encode("utf-8", "ignore")
826
+ except Exception:
827
+ pass
828
+ await cur.execute(
829
+ "INSERT OR REPLACE INTO checkpoints "
830
+ "(thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata) "
831
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
832
+ (target_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, cp_type, checkpoint_blob, updated_metadata_blob),
833
+ )
834
+ await cur.execute(
835
+ "SELECT checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value "
836
+ "FROM writes WHERE thread_id = ?",
837
+ (source_id,),
838
+ )
839
+ write_rows = await cur.fetchall()
840
+ for checkpoint_ns, checkpoint_id, task_id, idx, channel, w_type, value_blob in write_rows:
841
+ await cur.execute(
842
+ "INSERT OR REPLACE INTO writes "
843
+ "(thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) "
844
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
845
+ (target_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, w_type, value_blob),
846
+ )
847
+ await self.conn.commit()
848
+
849
+ async def aprune(
850
+ self, thread_ids: Sequence[str], *, strategy: str = "keep_latest"
851
+ ) -> None:
852
+ """Asynchronously prune checkpoints for the given threads according to the strategy.
853
+
854
+ Supports 'delete_all' / 'delete' and 'keep_latest' thread history pruning.
855
+ """
856
+ if strategy in ("delete_all", "delete"):
857
+ for tid in thread_ids:
858
+ await self.adelete_thread(tid)
859
+ return
860
+ if strategy != "keep_latest":
861
+ raise ValueError(f"Unknown prune strategy: {strategy}")
862
+
863
+ await self.setup()
864
+ async with self.lock, self.conn.cursor() as cur:
865
+ for tid in thread_ids:
866
+ await cur.execute(
867
+ "SELECT checkpoint_ns, MAX(checkpoint_id) FROM checkpoints "
868
+ "WHERE thread_id = ? GROUP BY checkpoint_ns",
869
+ (tid,),
870
+ )
871
+ latest_rows = await cur.fetchall()
872
+ for checkpoint_ns, latest_cid in latest_rows:
873
+ if latest_cid is None:
874
+ continue
875
+ await cur.execute(
876
+ "DELETE FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id != ?",
877
+ (tid, checkpoint_ns, latest_cid),
878
+ )
879
+ await cur.execute(
880
+ "DELETE FROM checkpoints WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id != ?",
881
+ (tid, checkpoint_ns, latest_cid),
882
+ )
883
+ await self.conn.commit()
884
+
885
+
886
+ @asynccontextmanager
887
+ async def get_checkpointer() -> AsyncGenerator[OpsCloudCheckpointer, None]:
888
+ """Provide an OpsCloudCheckpointer connected to the sessions database."""
889
+ _patch_aiosqlite()
890
+ db_path = get_db_path()
891
+ async with OpsCloudCheckpointer.from_conn_string(str(db_path)) as saver:
892
+ await _guard_sqlite_handle(saver.conn)
893
+ yield saver
894
+
895
+
896
+ class SessionManager:
897
+ """High-level facade for thread session lifecycle in OpsCloud."""
898
+
899
+ def __init__(self, db_path: Path | None = None) -> None:
900
+ if db_path is not None:
901
+ set_db_path(db_path)
902
+ self.db_path = get_db_path()
903
+
904
+ @staticmethod
905
+ def get_db_path() -> Path:
906
+ return get_db_path()
907
+
908
+ @staticmethod
909
+ async def list_threads(
910
+ limit: int = 50,
911
+ agent_name: str | None = None,
912
+ include_message_count: bool = True,
913
+ ) -> list[ThreadInfo]:
914
+ return await list_threads(
915
+ limit=limit,
916
+ agent_name=agent_name,
917
+ include_message_count=include_message_count,
918
+ )
919
+
920
+ @staticmethod
921
+ async def delete_thread(thread_id: str) -> bool:
922
+ return await delete_thread(thread_id)
923
+
924
+ @staticmethod
925
+ async def get_most_recent(agent_name: str | None = None) -> ThreadInfo | None:
926
+ return await get_most_recent(agent_name=agent_name)
927
+
928
+ @staticmethod
929
+ async def thread_exists(thread_id: str) -> bool:
930
+ return await thread_exists(thread_id)
931
+
932
+ @staticmethod
933
+ async def find_similar_threads(prefix: str, limit: int = 3) -> list[str]:
934
+ return await find_similar_threads(prefix, limit=limit)
935
+
936
+ @staticmethod
937
+ def get_checkpointer() -> Any:
938
+ return get_checkpointer()
939
+
940
+
941
+ __all__ = [
942
+ "OpsCloudCheckpointer",
943
+ "SessionManager",
944
+ "ThreadInfo",
945
+ "delete_thread",
946
+ "find_similar_threads",
947
+ "format_relative_timestamp",
948
+ "format_timestamp",
949
+ "generate_thread_id",
950
+ "get_checkpointer",
951
+ "get_db_path",
952
+ "get_most_recent",
953
+ "list_threads",
954
+ "prewarm_thread_message_counts",
955
+ "set_db_path",
956
+ "thread_exists",
957
+ ]