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.
- opscloud/__init__.py +5 -0
- opscloud/__main__.py +6 -0
- opscloud/_constants.py +53 -0
- opscloud/_debug.py +145 -0
- opscloud/_debug_buffer.py +250 -0
- opscloud/_textual_patches.py +367 -0
- opscloud/_version.py +15 -0
- opscloud/agent/__init__.py +27 -0
- opscloud/agent/config.py +168 -0
- opscloud/agent/factory.py +1103 -0
- opscloud/approval_mode.py +44 -0
- opscloud/backend/__init__.py +49 -0
- opscloud/backend/composite.py +89 -0
- opscloud/backend/local.py +183 -0
- opscloud/backend/registry.py +79 -0
- opscloud/backend/sandbox_config.py +107 -0
- opscloud/backend/sandbox_factory.py +432 -0
- opscloud/built_in_skills/remember/SKILL.md +124 -0
- opscloud/built_in_skills/skill-creator/SKILL.md +348 -0
- opscloud/built_in_skills/skill-creator/scripts/init_skill.py +371 -0
- opscloud/built_in_skills/skill-creator/scripts/quick_validate.py +165 -0
- opscloud/cli/__init__.py +1 -0
- opscloud/cli/commands/__init__.py +15 -0
- opscloud/cli/commands/auth.py +45 -0
- opscloud/cli/commands/doctor.py +100 -0
- opscloud/cli/commands/mcp.py +137 -0
- opscloud/cli/commands/threads.py +51 -0
- opscloud/cli/interactive.py +154 -0
- opscloud/cli/main.py +903 -0
- opscloud/cli/non_interactive.py +425 -0
- opscloud/cli/server_manager.py +58 -0
- opscloud/commands/__init__.py +19 -0
- opscloud/commands/_base.py +110 -0
- opscloud/commands/_guards.py +52 -0
- opscloud/commands/_router.py +146 -0
- opscloud/commands/_types.py +36 -0
- opscloud/commands/core/__init__.py +47 -0
- opscloud/commands/core/auth.py +94 -0
- opscloud/commands/core/bug.py +46 -0
- opscloud/commands/core/clear.py +106 -0
- opscloud/commands/core/cloud.py +185 -0
- opscloud/commands/core/compact.py +128 -0
- opscloud/commands/core/config_cmd.py +120 -0
- opscloud/commands/core/context.py +220 -0
- opscloud/commands/core/cost.py +44 -0
- opscloud/commands/core/doctor.py +535 -0
- opscloud/commands/core/effort.py +108 -0
- opscloud/commands/core/exit_cmd.py +41 -0
- opscloud/commands/core/fast.py +148 -0
- opscloud/commands/core/help_cmd.py +94 -0
- opscloud/commands/core/mcp.py +134 -0
- opscloud/commands/core/model.py +109 -0
- opscloud/commands/core/permissions.py +196 -0
- opscloud/commands/core/plugins.py +92 -0
- opscloud/commands/core/pool.py +108 -0
- opscloud/commands/core/resume.py +97 -0
- opscloud/commands/core/skills.py +178 -0
- opscloud/commands/power/__init__.py +51 -0
- opscloud/commands/power/agents.py +98 -0
- opscloud/commands/power/btw.py +85 -0
- opscloud/commands/power/copy.py +97 -0
- opscloud/commands/power/goal.py +548 -0
- opscloud/commands/power/loop.py +295 -0
- opscloud/commands/power/memory.py +287 -0
- opscloud/commands/power/review.py +152 -0
- opscloud/commands/power/rubric.py +287 -0
- opscloud/commands/power/runtime.py +321 -0
- opscloud/commands/power/skill_creator.py +70 -0
- opscloud/commands/power/skill_invoke.py +165 -0
- opscloud/commands/power/tasks.py +209 -0
- opscloud/commands/power/trace.py +117 -0
- opscloud/commands/power/ui_toggles.py +135 -0
- opscloud/commands/power/version.py +52 -0
- opscloud/commands/registry.py +176 -0
- opscloud/config/__init__.py +114 -0
- opscloud/config/adapters/__init__.py +5 -0
- opscloud/config/adapters/postgres.py +776 -0
- opscloud/config/adapters/sqlite.py +769 -0
- opscloud/config/aws.py +248 -0
- opscloud/config/cloud_profiles.py +437 -0
- opscloud/config/env_vars.py +81 -0
- opscloud/config/langsmith.py +319 -0
- opscloud/config/manifest.py +817 -0
- opscloud/config/metadata.py +205 -0
- opscloud/config/paths.py +604 -0
- opscloud/config/plugins.py +130 -0
- opscloud/config/settings.py +834 -0
- opscloud/config/store.py +453 -0
- opscloud/config/store_factory.py +97 -0
- opscloud/config/toml_config.py +562 -0
- opscloud/editor.py +173 -0
- opscloud/exceptions.py +65 -0
- opscloud/file_ops.py +217 -0
- opscloud/hooks/__init__.py +139 -0
- opscloud/hooks/env.py +44 -0
- opscloud/hooks/interrupt.py +121 -0
- opscloud/hooks/models/__init__.py +109 -0
- opscloud/hooks/models/adapters.py +19 -0
- opscloud/hooks/models/config.py +79 -0
- opscloud/hooks/models/domain.py +434 -0
- opscloud/hooks/models/transport.py +37 -0
- opscloud/hooks/tools.py +231 -0
- opscloud/input.py +48 -0
- opscloud/integrations/__init__.py +80 -0
- opscloud/integrations/base.py +153 -0
- opscloud/integrations/content_filter.py +119 -0
- opscloud/integrations/event_bus.py +480 -0
- opscloud/integrations/hooks.py +238 -0
- opscloud/integrations/identity.py +181 -0
- opscloud/integrations/notifications.py +134 -0
- opscloud/integrations/stream_bridge.py +397 -0
- opscloud/integrations/thread_store.py +81 -0
- opscloud/json_types.py +20 -0
- opscloud/mcp/__init__.py +57 -0
- opscloud/mcp/config.py +203 -0
- opscloud/mcp/discovery.py +229 -0
- opscloud/mcp/mcp_info.py +109 -0
- opscloud/mcp/middleware.py +102 -0
- opscloud/mcp/preload.py +505 -0
- opscloud/mcp/raw_config.py +149 -0
- opscloud/mcp/semantic_profiler.py +305 -0
- opscloud/mcp/session_manager.py +727 -0
- opscloud/mcp/trust.py +105 -0
- opscloud/memory/__init__.py +27 -0
- opscloud/memory/branch.py +61 -0
- opscloud/memory/guard.py +19 -0
- opscloud/memory/onboarding.py +120 -0
- opscloud/memory/registry.py +134 -0
- opscloud/memory/store.py +223 -0
- opscloud/middleware/__init__.py +106 -0
- opscloud/middleware/_repository_bounds.py +459 -0
- opscloud/middleware/ask_user.py +152 -0
- opscloud/middleware/auto_mode.py +60 -0
- opscloud/middleware/auto_mode_hitl.py +2434 -0
- opscloud/middleware/compaction.py +566 -0
- opscloud/middleware/configurable_model.py +233 -0
- opscloud/middleware/cost_tracking.py +1021 -0
- opscloud/middleware/glm_stall_recovery.py +109 -0
- opscloud/middleware/goal_criteria.py +1234 -0
- opscloud/middleware/goal_state_notice.py +712 -0
- opscloud/middleware/goal_tools.py +407 -0
- opscloud/middleware/headless_mcp_guard.py +88 -0
- opscloud/middleware/jev_model_router.py +1018 -0
- opscloud/middleware/local_context.py +460 -0
- opscloud/middleware/mcp_context.py +151 -0
- opscloud/middleware/mcp_middleware.py +146 -0
- opscloud/middleware/memory_guard.py +393 -0
- opscloud/middleware/model_retry.py +205 -0
- opscloud/middleware/registry.py +137 -0
- opscloud/middleware/reliable_rubric.py +709 -0
- opscloud/middleware/resume_state.py +91 -0
- opscloud/middleware/server_hooks.py +1189 -0
- opscloud/middleware/shell_allow_list.py +90 -0
- opscloud/middleware/skills.py +382 -0
- opscloud/middleware/subagent_artifacts.py +84 -0
- opscloud/middleware/subagent_telemetry.py +261 -0
- opscloud/middleware/subagents.py +243 -0
- opscloud/middleware/tool_filter.py +384 -0
- opscloud/middleware/unified_system_message.py +62 -0
- opscloud/model/__init__.py +37 -0
- opscloud/model/config.py +2992 -0
- opscloud/model/factory.py +477 -0
- opscloud/model/pool.py +459 -0
- opscloud/model/reasoning.py +282 -0
- opscloud/offload.py +173 -0
- opscloud/output.py +64 -0
- opscloud/plugins/__init__.py +189 -0
- opscloud/plugins/_json.py +22 -0
- opscloud/plugins/adapters/__init__.py +13 -0
- opscloud/plugins/adapters/agents.py +112 -0
- opscloud/plugins/adapters/commands.py +190 -0
- opscloud/plugins/adapters/mcp.py +363 -0
- opscloud/plugins/adapters/skills.py +132 -0
- opscloud/plugins/commands_cli.py +541 -0
- opscloud/plugins/discovery.py +512 -0
- opscloud/plugins/manifest.py +335 -0
- opscloud/plugins/marketplace.py +893 -0
- opscloud/plugins/models.py +334 -0
- opscloud/plugins/project_plugins.py +317 -0
- opscloud/plugins/protocol.py +31 -0
- opscloud/plugins/store.py +905 -0
- opscloud/plugins/substitution.py +122 -0
- opscloud/project_utils.py +145 -0
- opscloud/prompts/__init__.py +37 -0
- opscloud/prompts/system.py +483 -0
- opscloud/prompts/templates/system_prompt.md +166 -0
- opscloud/prompts/types.py +54 -0
- opscloud/rubrics/__init__.py +42 -0
- opscloud/rubrics/evaluator.py +404 -0
- opscloud/rubrics/evidence_extractor.py +229 -0
- opscloud/rubrics/generator.py +239 -0
- opscloud/rubrics/jev_compiler.py +175 -0
- opscloud/rubrics/jev_grader.py +297 -0
- opscloud/rubrics/middleware.py +8 -0
- opscloud/schema/__init__.py +107 -0
- opscloud/schema/interrupts.py +981 -0
- opscloud/security/__init__.py +91 -0
- opscloud/security/approval_mode.py +528 -0
- opscloud/security/approval_mode_source.py +230 -0
- opscloud/security/cli_ast_evaluator.py +552 -0
- opscloud/security/jev_classifier.py +651 -0
- opscloud/security/shell_safety.py +340 -0
- opscloud/security/unicode_security.py +439 -0
- opscloud/security/url_validation.py +160 -0
- opscloud/server/__init__.py +13 -0
- opscloud/server/_server_config.py +158 -0
- opscloud/server/server.py +242 -0
- opscloud/server/server_graph.py +93 -0
- opscloud/skills/__init__.py +91 -0
- opscloud/skills/commands.py +1099 -0
- opscloud/skills/invocation.py +200 -0
- opscloud/skills/load.py +388 -0
- opscloud/skills/loader.py +23 -0
- opscloud/skills/merge.py +65 -0
- opscloud/skills/registry.py +263 -0
- opscloud/skills/sources.py +174 -0
- opscloud/skills/trust.py +417 -0
- opscloud/state/__init__.py +142 -0
- opscloud/state/base.py +154 -0
- opscloud/state/cloud_context.py +262 -0
- opscloud/state/goal_channels.py +124 -0
- opscloud/state/goal_state_limits.py +175 -0
- opscloud/state/resume_state.py +58 -0
- opscloud/state/service_context.py +281 -0
- opscloud/state/session.py +957 -0
- opscloud/state/state_migration.py +86 -0
- opscloud/subagents/__init__.py +30 -0
- opscloud/subagents/loader.py +331 -0
- opscloud/subagents/subagents_parser.py +117 -0
- opscloud/subagents/types.py +25 -0
- opscloud/tools/__init__.py +36 -0
- opscloud/tools/catalog.py +37 -0
- opscloud/tools/display.py +24 -0
- opscloud/tools/fetch_url.py +176 -0
- opscloud/tools/goal_tools.py +423 -0
- opscloud/tools/registry.py +105 -0
- opscloud/tools/thread.py +32 -0
- opscloud/tools/web_search.py +215 -0
- opscloud/ui/__init__.py +119 -0
- opscloud/ui/app.py +5400 -0
- opscloud/ui/app.tcss +479 -0
- opscloud/ui/clipboard.py +147 -0
- opscloud/ui/command_registry.py +482 -0
- opscloud/ui/message_store.py +152 -0
- opscloud/ui/permission_store.py +360 -0
- opscloud/ui/preferences.py +113 -0
- opscloud/ui/prompt_manager.py +96 -0
- opscloud/ui/remote_client.py +505 -0
- opscloud/ui/textual_adapter.py +1269 -0
- opscloud/ui/theme.py +728 -0
- opscloud/ui/ui_help.py +415 -0
- opscloud/ui/widgets/__init__.py +383 -0
- opscloud/ui/widgets/_ask_user_types.py +81 -0
- opscloud/ui/widgets/_copy_spans.py +52 -0
- opscloud/ui/widgets/_inline_prompt.py +290 -0
- opscloud/ui/widgets/_js_eval_display.py +139 -0
- opscloud/ui/widgets/_links.py +175 -0
- opscloud/ui/widgets/_tool_stream.py +308 -0
- opscloud/ui/widgets/agent_selector.py +297 -0
- opscloud/ui/widgets/approval.py +741 -0
- opscloud/ui/widgets/ask_user.py +539 -0
- opscloud/ui/widgets/auth.py +695 -0
- opscloud/ui/widgets/auth_manager.py +214 -0
- opscloud/ui/widgets/auto_mode_notice.py +146 -0
- opscloud/ui/widgets/autocomplete.py +709 -0
- opscloud/ui/widgets/chat_input.py +805 -0
- opscloud/ui/widgets/cloud_selector.py +528 -0
- opscloud/ui/widgets/config_manager.py +652 -0
- opscloud/ui/widgets/debug_console.py +1269 -0
- opscloud/ui/widgets/devops_renderers.py +184 -0
- opscloud/ui/widgets/diff.py +237 -0
- opscloud/ui/widgets/effort_selector.py +166 -0
- opscloud/ui/widgets/goal_review.py +612 -0
- opscloud/ui/widgets/goal_status.py +57 -0
- opscloud/ui/widgets/infra_panel.py +141 -0
- opscloud/ui/widgets/install_confirm.py +114 -0
- opscloud/ui/widgets/loading.py +170 -0
- opscloud/ui/widgets/mcp_viewer.py +1204 -0
- opscloud/ui/widgets/messages.py +4461 -0
- opscloud/ui/widgets/model_selector.py +863 -0
- opscloud/ui/widgets/notification_center.py +108 -0
- opscloud/ui/widgets/notification_settings.py +119 -0
- opscloud/ui/widgets/operation_card.py +106 -0
- opscloud/ui/widgets/permissions_manager.py +667 -0
- opscloud/ui/widgets/plugin_manager.py +1212 -0
- opscloud/ui/widgets/pool_selector.py +376 -0
- opscloud/ui/widgets/preamble.py +53 -0
- opscloud/ui/widgets/skills_viewer.py +432 -0
- opscloud/ui/widgets/status.py +1037 -0
- opscloud/ui/widgets/subagent_panel.py +927 -0
- opscloud/ui/widgets/theme_selector.py +232 -0
- opscloud/ui/widgets/thread_selector.py +358 -0
- opscloud/ui/widgets/toast.py +31 -0
- opscloud/ui/widgets/tool_display.py +325 -0
- opscloud/ui/widgets/tool_renderers.py +234 -0
- opscloud/ui/widgets/tool_widgets.py +278 -0
- opscloud/ui/widgets/welcome.py +421 -0
- opscloud/ui/widgets/welcome_popup.py +102 -0
- opscloud/utils/__init__.py +24 -0
- opscloud/utils/cost_estimation.py +169 -0
- opscloud/utils/git.py +390 -0
- opscloud/utils/logger.py +571 -0
- opscloud/utils/session_stats.py +115 -0
- opscloud/utils/startup_error.py +37 -0
- talkops_opscloud-0.3.0b1.dist-info/METADATA +449 -0
- talkops_opscloud-0.3.0b1.dist-info/RECORD +309 -0
- talkops_opscloud-0.3.0b1.dist-info/WHEEL +4 -0
- talkops_opscloud-0.3.0b1.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|